Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
loader.lazyRequireGetter(
this,
"CombinedProgress",
"resource://devtools/client/accessibility/utils/audit.js",
true
);
const {
accessibility: { AUDIT_TYPE },
} = require("resource://devtools/shared/constants.js");
const {
FILTERS,
} = require("resource://devtools/client/accessibility/constants.js");
/**
* Component responsible for tracking all Accessibility fronts in parent and
* content processes.
*/
class AccessibilityProxy {
#panel;
#initialized;
constructor(commands, panel) {
this.commands = commands;
this.#panel = panel;
this.#initialized = false;
this._accessibilityWalkerFronts = new Set();
this.lifecycleEvents = new Map();
this.accessibilityEvents = new Map();
this.audit = this.audit.bind(this);
this.enableAccessibility = this.enableAccessibility.bind(this);
this.getAccessibilityTreeRoot = this.getAccessibilityTreeRoot.bind(this);
this.resetAccessiblity = this.resetAccessiblity.bind(this);
this.startListeningForAccessibilityEvents =
this.startListeningForAccessibilityEvents.bind(this);
this.startListeningForLifecycleEvents =
this.startListeningForLifecycleEvents.bind(this);
this.startListeningForParentLifecycleEvents =
this.startListeningForParentLifecycleEvents.bind(this);
this.stopListeningForAccessibilityEvents =
this.stopListeningForAccessibilityEvents.bind(this);
this.stopListeningForLifecycleEvents =
this.stopListeningForLifecycleEvents.bind(this);
this.stopListeningForParentLifecycleEvents =
this.stopListeningForParentLifecycleEvents.bind(this);
this.highlightAccessible = this.highlightAccessible.bind(this);
this.unhighlightAccessible = this.unhighlightAccessible.bind(this);
this.onTargetAvailable = this.onTargetAvailable.bind(this);
this.onTargetDestroyed = this.onTargetDestroyed.bind(this);
this.onTargetSelected = this.onTargetSelected.bind(this);
this.onAccessibilityFrontAvailable =
this.onAccessibilityFrontAvailable.bind(this);
this.onAccessibilityFrontDestroyed =
this.onAccessibilityFrontDestroyed.bind(this);
this.onAccessibleWalkerFrontAvailable =
this.onAccessibleWalkerFrontAvailable.bind(this);
this.onAccessibleWalkerFrontDestroyed =
this.onAccessibleWalkerFrontDestroyed.bind(this);
this.unhighlightBeforeCalling = this.unhighlightBeforeCalling.bind(this);
this.toggleDisplayTabbingOrder = this.toggleDisplayTabbingOrder.bind(this);
}
get enabled() {
return this.accessibilityFront && this.accessibilityFront.enabled;
}
/**
* Indicates whether the accessibility service is enabled.
*/
get canBeEnabled() {
return this.parentAccessibilityFront.canBeEnabled;
}
get currentTarget() {
return this.commands.targetCommand.selectedTargetFront;
}
/**
* Perform an audit for a given filter.
*
* @param {String} filter
* Type of an audit to perform.
* @param {Function} onProgress
* Audit progress callback.
*
* @return {Promise}
* Resolves when the audit for every document, that each of the frame
* accessibility walkers traverse, completes.
*/
async audit(filter, onProgress) {
const types = filter === FILTERS.ALL ? Object.values(AUDIT_TYPE) : [filter];
const targetTypes = [this.commands.targetCommand.TYPES.FRAME];
const targets =
await this.commands.targetCommand.getAllTargetsInSelectedTargetTree(
targetTypes
);
const progress = new CombinedProgress({
onProgress,
totalFrames: targets.length,
});
const audits = await this.withAllAccessibilityWalkerFronts(
async accessibleWalkerFront =>
accessibleWalkerFront.audit({
types,
onProgress: progress.onProgressForWalker.bind(
progress,
accessibleWalkerFront
),
// If a frame was selected in the iframe picker, we don't want to retrieve the
// ancestries at it would mess with the tree structure and would make it misbehave.
retrieveAncestries:
this.commands.targetCommand.isTopLevelTargetSelected(),
})
);
// Accumulate all audits into a single structure.
const combinedAudit = { ancestries: [] };
for (const audit of audits) {
// If any of the audits resulted in an error, no need to continue.
if (audit.error) {
return audit;
}
combinedAudit.ancestries.push(...audit.ancestries);
}
return combinedAudit;
}
async toggleDisplayTabbingOrder(displayTabbingOrder) {
if (displayTabbingOrder) {
const { walker: domWalkerFront } =
await this.currentTarget.getFront("inspector");
await this.accessibilityFront.accessibleWalkerFront.showTabbingOrder(
await domWalkerFront.getRootNode(),
0
);
} else {
// we don't want to use withAllAccessibilityWalkerFronts as it only acts on selected
// target tree, and we want to hide _all_ highlighters.
const accessibilityFronts =
await this.commands.targetCommand.getAllFronts(
[this.commands.targetCommand.TYPES.FRAME],
"accessibility"
);
await Promise.all(
accessibilityFronts.map(accessibilityFront =>
accessibilityFront.accessibleWalkerFront.hideTabbingOrder()
)
);
}
}
async enableAccessibility() {
// Accessibility service is initialized using the parent accessibility
// front. That, in turn, initializes accessibility service in all content
// processes. We need to wait until that happens to be sure platform
// accessibility is fully enabled.
const enabled = this.accessibilityFront.once("init");
await this.parentAccessibilityFront.enable();
await enabled;
}
/**
* Return the topmost level accessibility walker to be used as the root of
* the accessibility tree view.
*
* @return {Object}
* Topmost accessibility walker.
*/
getAccessibilityTreeRoot() {
return this.accessibilityFront.accessibleWalkerFront;
}
/**
* Look up accessibility fronts (get an existing one or create a new one) for
* all existing target fronts and run a task with each one of them.
* @param {Function} task
* Function to execute with each accessiblity front.
*/
async withAllAccessibilityFronts(taskFn) {
const accessibilityFronts = await this.commands.targetCommand.getAllFronts(
[this.commands.targetCommand.TYPES.FRAME],
"accessibility",
{
// only get the fronts for the selected frame tree, in case a specific document
// is selected in the iframe picker (if not, the top-level target is considered
// as the selected target)
onlyInSelectedTargetTree: true,
}
);
const tasks = [];
for (const accessibilityFront of accessibilityFronts) {
tasks.push(taskFn(accessibilityFront));
}
return Promise.all(tasks);
}
/**
* Look up accessibility walker fronts (get an existing one or create a new
* one using accessibility front) for all existing target fronts and run a
* task with each one of them.
* @param {Function} task
* Function to execute with each accessiblity walker front.
*/
withAllAccessibilityWalkerFronts(taskFn) {
return this.withAllAccessibilityFronts(async accessibilityFront =>
taskFn(accessibilityFront.accessibleWalkerFront)
);
}
/**
* Unhighlight previous accessible object if we switched between processes and
* call the appropriate event handler.
*/
unhighlightBeforeCalling(listener) {
return async accessible => {
if (accessible) {
const accessibleWalkerFront = accessible.getParent();
if (this._currentAccessibleWalkerFront !== accessibleWalkerFront) {
if (this._currentAccessibleWalkerFront) {
await this._currentAccessibleWalkerFront.unhighlight();
}
this._currentAccessibleWalkerFront = accessibleWalkerFront;
}
}
await listener(accessible);
};
}
/**
* Start picking and add walker listeners.
* @param {Boolean} doFocus
* If true, move keyboard focus into content.
*/
pick(doFocus, onHovered, onPicked, onPreviewed, onCanceled) {
return this.withAllAccessibilityWalkerFronts(
async accessibleWalkerFront => {
this.startListening(accessibleWalkerFront, {
events: {
"picker-accessible-hovered":
this.unhighlightBeforeCalling(onHovered),
"picker-accessible-picked": this.unhighlightBeforeCalling(onPicked),
"picker-accessible-previewed":
this.unhighlightBeforeCalling(onPreviewed),
"picker-accessible-canceled":
this.unhighlightBeforeCalling(onCanceled),
},
// Only register listeners once (for top level), no need to register
// them for all walkers again and again.
register: accessibleWalkerFront.targetFront.isTopLevel,
});
await accessibleWalkerFront.pick(
// Only pass doFocus to the top level accessibility walker front.
doFocus && accessibleWalkerFront.targetFront.isTopLevel
);
}
);
}
/**
* Stop picking and remove all walker listeners.
*/
async cancelPick() {
this._currentAccessibleWalkerFront = null;
return this.withAllAccessibilityWalkerFronts(
async accessibleWalkerFront => {
await accessibleWalkerFront.cancelPick();
this.stopListening(accessibleWalkerFront, {
events: {
"picker-accessible-hovered": null,
"picker-accessible-picked": null,
"picker-accessible-previewed": null,
"picker-accessible-canceled": null,
},
// Only unregister listeners once (for top level), no need to
// unregister them for all walkers again and again.
unregister: accessibleWalkerFront.targetFront.isTopLevel,
});
}
);
}
async resetAccessiblity() {
const { enabled } = this.accessibilityFront;
const { canBeEnabled, canBeDisabled } = this.parentAccessibilityFront;
return { enabled, canBeDisabled, canBeEnabled };
}
startListening(front, { events, register = false } = {}) {
for (const [type, listener] of Object.entries(events)) {
front.on(type, listener);
if (register) {
this.registerEvent(front, type, listener);
}
}
}
stopListening(front, { events, unregister = false } = {}) {
for (const [type, listener] of Object.entries(events)) {
front.off(type, listener);
if (unregister) {
this.unregisterEvent(front, type, listener);
}
}
}
startListeningForAccessibilityEvents(events) {
for (const accessibleWalkerFront of this._accessibilityWalkerFronts.values()) {
this.startListening(accessibleWalkerFront, {
events,
// Only register listeners once (for top level), no need to register
// them for all walkers again and again.
register: accessibleWalkerFront.targetFront.isTopLevel,
});
}
}
stopListeningForAccessibilityEvents(events) {
for (const accessibleWalkerFront of this._accessibilityWalkerFronts.values()) {
this.stopListening(accessibleWalkerFront, {
events,
// Only unregister listeners once (for top level), no need to unregister
// them for all walkers again and again.
unregister: accessibleWalkerFront.targetFront.isTopLevel,
});
}
}
startListeningForLifecycleEvents(events) {
this.startListening(this.accessibilityFront, { events, register: true });
}
stopListeningForLifecycleEvents(events) {
this.stopListening(this.accessibilityFront, { events, unregister: true });
}
startListeningForParentLifecycleEvents(events) {
this.startListening(this.parentAccessibilityFront, {
events,
register: false,
});
}
stopListeningForParentLifecycleEvents(events) {
this.stopListening(this.parentAccessibilityFront, {
events,
unregister: false,
});
}
highlightAccessible(accessibleFront, options) {
if (!accessibleFront) {
return;
}
const accessibleWalkerFront = accessibleFront.getParent();
if (!accessibleWalkerFront) {
return;
}
accessibleWalkerFront
.highlightAccessible(accessibleFront, options)
.catch(error => {
// Only report an error where there's still a commands instance.
// Ignore cases where toolbox is already destroyed.
if (this.commands) {
console.error(error);
}
});
}
unhighlightAccessible(accessibleFront) {
if (!accessibleFront) {
return;
}
const accessibleWalkerFront = accessibleFront.getParent();
if (!accessibleWalkerFront) {
return;
}
accessibleWalkerFront.unhighlight().catch(error => {
// Only report an error where there's still a commands instance.
// Ignore cases where toolbox is already destroyed.
if (this.commands) {
console.error(error);
}
});
}
async initialize() {
// Initialize it first as it may be used on target selection when calling watchTargets
this.parentAccessibilityFront =
await this.commands.targetCommand.rootFront.getFront(
"parentaccessibility"
);
await this.commands.targetCommand.watchTargets({
types: [this.commands.targetCommand.TYPES.FRAME],
onAvailable: this.onTargetAvailable,
onSelected: this.onTargetSelected,
onDestroyed: this.onTargetDestroyed,
});
// Enable accessibility service if necessary.
if (this.canBeEnabled && !this.enabled) {
await this.enableAccessibility();
}
this.#initialized = true;
}
get supports() {
// Retrieve backward compatibility traits.
// New API's must be described in the "getTraits" method of the AccessibilityActor.
return this.accessibilityFront.traits;
}
destroy() {
this.commands.targetCommand.unwatchTargets({
types: [this.commands.targetCommand.TYPES.FRAME],
onAvailable: this.onTargetAvailable,
onSelected: this.onTargetSelected,
onDestroyed: this.onTargetDestroyed,
});
this.lifecycleEvents.clear();
this.accessibilityEvents.clear();
this.accessibilityFront = null;
this.parentAccessibilityFront = null;
this.simulatorFront = null;
this.simulate = null;
this.commands = null;
}
_getEvents(front) {
return front.typeName === "accessiblewalker"
? this.accessibilityEvents
: this.lifecycleEvents;
}
registerEvent(front, type, listener) {
const events = this._getEvents(front);
if (events.has(type)) {
events.get(type).add(listener);
} else {
events.set(type, new Set([listener]));
}
}
unregisterEvent(front, type, listener) {
const events = this._getEvents(front);
if (!events.has(type)) {
return;
}
if (!listener) {
events.delete(type);
return;
}
const listeners = events.get(type);
if (listeners.has(listener)) {
listeners.delete(listener);
}
if (!listeners.size) {
events.delete(type);
}
}
onAccessibilityFrontAvailable(accessibilityFront) {
accessibilityFront.watchFronts(
"accessiblewalker",
this.onAccessibleWalkerFrontAvailable,
this.onAccessibleWalkerFrontDestroyed
);
}
onAccessibilityFrontDestroyed(accessibilityFront) {
accessibilityFront.unwatchFronts(
"accessiblewalker",
this.onAccessibleWalkerFrontAvailable,
this.onAccessibleWalkerFrontDestroyed
);
}
onAccessibleWalkerFrontAvailable(accessibleWalkerFront) {
this._accessibilityWalkerFronts.add(accessibleWalkerFront);
// Apply all existing accessible walker front event listeners to the new
// front.
for (const [type, listeners] of this.accessibilityEvents.entries()) {
for (const listener of listeners) {
accessibleWalkerFront.on(type, listener);
}
}
}
onAccessibleWalkerFrontDestroyed(accessibleWalkerFront) {
this._accessibilityWalkerFronts.delete(accessibleWalkerFront);
// Remove all existing accessible walker front event listeners from the
// destroyed front.
for (const [type, listeners] of this.accessibilityEvents.entries()) {
for (const listener of listeners) {
accessibleWalkerFront.off(type, listener);
}
}
}
async onTargetAvailable({ targetFront }) {
targetFront.watchFronts(
"accessibility",
this.onAccessibilityFrontAvailable,
this.onAccessibilityFrontDestroyed
);
if (!targetFront.isTopLevel) {
return;
}
// Clear all the fronts collected by `watchFronts` on the previous set of targets/documents.
this._accessibilityWalkerFronts.clear();
}
async onTargetDestroyed({ targetFront }) {
targetFront.unwatchFronts(
"accessibility",
this.onAccessibilityFrontAvailable,
this.onAccessibilityFrontDestroyed
);
}
async onTargetSelected({ targetFront }) {
this.accessibilityFront = await targetFront.getFront("accessibility");
this.simulatorFront = this.accessibilityFront.simulatorFront;
if (this.simulatorFront) {
this.simulate = types => this.simulatorFront.simulate({ types });
} else {
this.simulate = null;
}
await this.toggleDisplayTabbingOrder(false);
// Move accessibility front lifecycle event listeners to a new top level
// front.
for (const [type, listeners] of this.lifecycleEvents.entries()) {
for (const listener of listeners.values()) {
this.accessibilityFront.on(type, listener);
}
}
// Hold on refreshing the view on initialization.
// This will be done by the Panel class after everything is setup.
// (we especially need to wait for the a11y service to be started)
if (this.#initialized) {
await this.#panel.forceRefresh();
}
}
}
exports.AccessibilityProxy = AccessibilityProxy;