chromium/extensions/renderer/resources/automation/automation_custom_bindings.js

// Copyright 2014 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// Custom bindings for the automation API.
const AutomationRootNode = require('automationNode').AutomationRootNode;
const automationInternal = getInternalApi('automationInternal');
const exceptionHandler = require('uncaught_exception_handler');
const logging = requireNative('logging');
const nativeAutomationInternal = requireNative('automationInternal');
const DestroyAccessibilityTree =
    nativeAutomationInternal.DestroyAccessibilityTree;
const StartCachingAccessibilityTrees =
    nativeAutomationInternal.StartCachingAccessibilityTrees;
const StopCachingAccessibilityTrees =
    nativeAutomationInternal.StopCachingAccessibilityTrees;
const AddTreeChangeObserver = nativeAutomationInternal.AddTreeChangeObserver;
const RemoveTreeChangeObserver =
    nativeAutomationInternal.RemoveTreeChangeObserver;
const GetFocusNative = nativeAutomationInternal.GetFocus;
const GetAccessibilityFocusNative =
    nativeAutomationInternal.GetAccessibilityFocus;
const SetDesktopID = nativeAutomationInternal.SetDesktopID;

// A namespace to export utility functions to other files in automation.
const automationUtil = function() {};

// TODO(aboxhall): Look into using WeakMap
let idToCallback = {};

let desktopId;
let desktopTree;

automationUtil.storeTreeCallback = function(id, callback) {
  if (!callback) {
    return;
  }

  const targetTree = AutomationRootNode.get(id);
  if (!targetTree) {
    // If we haven't cached the tree, hold the callback until the tree is
    // populated by the initial onAccessibilityEvent call.
    if (id in idToCallback) {
      idToCallback[id].push(callback);
    } else {
      idToCallback[id] = [callback];
    }
  } else {
    callback(targetTree);
  }
};

/**
 * Global list of tree change observers.
 * @type {Object<number, TreeChangeObserver>}
 */
automationUtil.treeChangeObserverMap = {};

/**
 * The id of the next tree change observer.
 * @type {number}
 */
automationUtil.nextTreeChangeObserverId = 1;

apiBridge.registerCustomHook(function(bindingsAPI) {
  const apiFunctions = bindingsAPI.apiFunctions;

  apiFunctions.setHandleRequest('getDesktop', function(callback) {
    StartCachingAccessibilityTrees();
    if (desktopId !== undefined) {
      desktopTree = AutomationRootNode.get(desktopId);
    }
    if (!desktopTree) {
      automationInternal.enableDesktop(function(treeId) {
        if (bindingUtil.hasLastError()) {
          AutomationRootNode.destroy(treeId);
          desktopId = undefined;
          SetDesktopID('');
          callback();
          return;
        }
        desktopId = treeId;
        SetDesktopID(desktopId);
        desktopTree = AutomationRootNode.getOrCreate(desktopId);
        callback(desktopTree);

        // TODO(dtseng): Disable desktop tree once desktop object goes out of
        // scope.
      });
    } else {
      callback(desktopTree);
    }
  });

  apiFunctions.setHandleRequest('getFocus', function(callback) {
    const focusedNodeInfo = GetFocusNative();
    if (!focusedNodeInfo) {
      callback(null);
      return;
    }
    const tree = AutomationRootNode.getOrCreate(focusedNodeInfo.treeId);
    if (tree) {
      callback(privates(tree).impl.get(focusedNodeInfo.nodeId));
      return;
    }
  });

  apiFunctions.setHandleRequest('getAccessibilityFocus', function(callback) {
    const focusedNodeInfo = GetAccessibilityFocusNative();
    if (!focusedNodeInfo) {
      callback(null);
      return;
    }
    const tree = AutomationRootNode.getOrCreate(focusedNodeInfo.treeId);
    if (tree) {
      callback(privates(tree).impl.get(focusedNodeInfo.nodeId));
    }
  });

  function removeTreeChangeObserver(observer) {
    for (const id in automationUtil.treeChangeObserverMap) {
      if (automationUtil.treeChangeObserverMap[id] == observer) {
        RemoveTreeChangeObserver(id);
        delete automationUtil.treeChangeObserverMap[id];
        return;
      }
    }
  }
  apiFunctions.setHandleRequest('removeTreeChangeObserver', function(observer) {
    removeTreeChangeObserver(observer);
  });

  function addTreeChangeObserver(filter, observer) {
    removeTreeChangeObserver(observer);
    const id = automationUtil.nextTreeChangeObserverId++;
    AddTreeChangeObserver(id, filter);
    automationUtil.treeChangeObserverMap[id] = observer;
  }
  apiFunctions.setHandleRequest('addTreeChangeObserver',
      function(filter, observer) {
    addTreeChangeObserver(filter, observer);
  });

  apiFunctions.setHandleRequest('setDocumentSelection', function(params) {
    const anchorNodeImpl = privates(params.anchorObject).impl;
    const focusNodeImpl = privates(params.focusObject).impl;
    if (anchorNodeImpl.treeID !== focusNodeImpl.treeID) {
      throw new Error('Selection anchor and focus must be in the same tree.');
    }
    if (anchorNodeImpl.treeID === desktopId) {
      throw new Error('Use AutomationNode.setSelection to set the selection ' +
          'in the desktop tree.');
    }
    automationInternal.performAction(
        {
          treeID: anchorNodeImpl.treeID,
          automationNodeID: anchorNodeImpl.id,
          actionType: 'setSelection',
        },
        {
          focusNodeID: focusNodeImpl.id,
          anchorOffset: params.anchorOffset,
          focusOffset: params.focusOffset,
        });
  });
});

automationInternal.onChildTreeID.addListener(function(childTreeId) {
  const targetTree = AutomationRootNode.get(childTreeId);

  // If the tree is already loded, or if we previously requested it be loaded
  // (i.e. have a callback for it), don't try to do so again.
  if (targetTree || idToCallback[childTreeId]) {
    return;
  }

  // A WebView in the desktop tree has a different AX tree as its child.
  // When we encounter a WebView with a child AX tree id that we don't
  // currently have cached, explicitly request that AX tree from the
  // browser process and set up a callback when it loads to attach that
  // tree as a child of this node and fire appropriate events.
  automationUtil.storeTreeCallback(childTreeId, function(root) {
    const rootImpl = privates(root).impl;
    rootImpl.dispatchEvent('loadComplete', 'page');
    if (rootImpl.parent) {
      privates(rootImpl.parent).impl.dispatchEvent('childrenChanged');
    }
  }, true);

  automationInternal.enableTree(childTreeId);
});

automationInternal.onTreeChange.addListener(function(
    observerID, treeID, nodeID, changeType) {
  const tree = AutomationRootNode.getOrCreate(treeID);
  if (!tree) {
    return;
  }

  const node = privates(tree).impl.get(nodeID);
  if (!node) {
    return;
  }

  const observer = automationUtil.treeChangeObserverMap[observerID];
  if (!observer) {
    return;
  }

  try {
    observer({target: node, type: changeType});
  } catch (e) {
    exceptionHandler.handle('Error in tree change observer for ' +
        changeType, e);
  }
});

automationInternal.onNodesRemoved.addListener(function(treeID, nodeIDs) {
  const tree = AutomationRootNode.getOrCreate(treeID);
  if (!tree) {
    return;
  }

  for (let i = 0; i < nodeIDs.length; i++) {
    privates(tree).impl.remove(nodeIDs[i]);
  }
});

automationInternal.onAllAutomationEventListenersRemoved.addListener(() => {
  if (!desktopId) {
    return;
  }
  automationInternal.disableDesktop(() => {
    desktopId = undefined;
    desktopTree = undefined;
    idToCallback = {};
    AutomationRootNode.destroyAll();
    StopCachingAccessibilityTrees();
  });
});

/**
 * Dispatch accessibility events fired on individual nodes to its
 * corresponding AutomationNode.
 */
automationInternal.onAccessibilityEvent.addListener(function(eventParams) {
  const id = eventParams.treeID;
  const targetTree = AutomationRootNode.getOrCreate(id);
  if (eventParams.eventType == 'mediaStartedPlaying' ||
      eventParams.eventType == 'mediaStoppedPlaying') {
    // These events are global to the tree.
    eventParams.targetID = privates(targetTree).impl.id;
  }

  privates(targetTree).impl.onAccessibilityEvent(eventParams);

  // If we're not waiting on a callback, we can early out here.
  if (!(id in idToCallback)) {
    return;
  }

  // We usually get a 'placeholder' tree first, which doesn't have any url
  // attribute or child nodes. If we've got that, wait for the full tree before
  // calling the callback.
  // TODO(dmazzoni): Don't send down placeholder (crbug.com/397553)
  if (id != desktopId && !targetTree.url && targetTree.children.length == 0) {
    return;
  }

  // If the tree wasn't available, the callback will have been cached in
  // idToCallback, so call and delete it now that we have the complete tree.
  for (let i = 0; i < idToCallback[id].length; i++) {
    const callback = idToCallback[id][i];
    callback(targetTree);
  }
  delete idToCallback[id];
});

automationInternal.onAccessibilityTreeDestroyed.addListener(function(id) {
  // Destroy the AutomationRootNode.
  const targetTree = AutomationRootNode.get(id);
  if (targetTree) {
    privates(targetTree).impl.destroy();
    AutomationRootNode.destroy(id);
  } else {
    logging.WARNING('no targetTree to destroy');
  }

  // Destroy the native cache of the accessibility tree.
  DestroyAccessibilityTree(id);
});

automationInternal.onAccessibilityTreeSerializationError.addListener(
    function(id) {
  automationInternal.enableTree(id);
});

automationInternal.onActionResult.addListener(function(
    treeID, requestID, result) {
  const targetTree = AutomationRootNode.get(treeID);
  if (!targetTree) {
    return;
  }

  privates(targetTree).impl.onActionResult(requestID, result);
});

automationInternal.onGetTextLocationResult.addListener(function(
    textLocationParams) {
  const targetTree = AutomationRootNode.get(textLocationParams.treeID);
  if (!targetTree) {
    return;
  }
  privates(targetTree).impl.onGetTextLocationResult(textLocationParams);
});