summaryrefslogtreecommitdiffstats
path: root/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs')
-rw-r--r--mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs1367
1 files changed, 1367 insertions, 0 deletions
diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs
new file mode 100644
index 0000000000..ae821a3656
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs
@@ -0,0 +1,1367 @@
+/* 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/. */
+
+import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const PRIVATE_BROWSING_PERMISSION = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+};
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ ExtensionProcessCrashObserver: "resource://gre/modules/Extension.sys.mjs",
+ GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("Console");
+
+export var DownloadTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+
+ // maps numeric IDs to DownloadItem objects
+ this._downloads = new Map();
+ }
+
+ onEvent(event, data, callback) {
+ switch (event) {
+ case "GeckoView:WebExtension:DownloadChanged": {
+ const downloadItem = this.getDownloadItemById(data.downloadItemId);
+
+ if (!downloadItem) {
+ callback.onError("Error: Trying to update unknown download");
+ return;
+ }
+
+ const delta = downloadItem.update(data);
+ if (delta) {
+ this.emit("download-changed", {
+ delta,
+ downloadItem,
+ });
+ }
+ }
+ }
+ }
+
+ addDownloadItem(item) {
+ this._downloads.set(item.id, item);
+ }
+
+ /**
+ * Finds and returns a DownloadItem with a certain numeric ID
+ *
+ * @param {number} id
+ * @returns {DownloadItem} download item
+ */
+ getDownloadItemById(id) {
+ return this._downloads.get(id);
+ }
+})();
+
+/** Provides common logic between page and browser actions */
+export class ExtensionActionHelper {
+ constructor({
+ tabTracker,
+ windowTracker,
+ tabContext,
+ properties,
+ extension,
+ }) {
+ this.tabTracker = tabTracker;
+ this.windowTracker = windowTracker;
+ this.tabContext = tabContext;
+ this.properties = properties;
+ this.extension = extension;
+ }
+
+ getTab(aTabId) {
+ if (aTabId !== null) {
+ return this.tabTracker.getTab(aTabId);
+ }
+ return null;
+ }
+
+ getWindow(aWindowId) {
+ if (aWindowId !== null) {
+ return this.windowTracker.getWindow(aWindowId);
+ }
+ return null;
+ }
+
+ extractProperties(aAction) {
+ const merged = {};
+ for (const p of this.properties) {
+ merged[p] = aAction[p];
+ }
+ return merged;
+ }
+
+ eventDispatcherFor(aTabId) {
+ if (!aTabId) {
+ return lazy.EventDispatcher.instance;
+ }
+
+ const windowId = lazy.GeckoViewTabBridge.tabIdToWindowId(aTabId);
+ const window = this.windowTracker.getWindow(windowId);
+ return window.WindowEventDispatcher;
+ }
+
+ sendRequest(aTabId, aData) {
+ return this.eventDispatcherFor(aTabId).sendRequest({
+ ...aData,
+ aTabId,
+ extensionId: this.extension.id,
+ });
+ }
+}
+
+class EmbedderPort {
+ constructor(portId, messenger) {
+ this.id = portId;
+ this.messenger = messenger;
+ this.dispatcher = lazy.EventDispatcher.byName(`port:${portId}`);
+ this.dispatcher.registerListener(this, [
+ "GeckoView:WebExtension:PortMessageFromApp",
+ "GeckoView:WebExtension:PortDisconnect",
+ ]);
+ }
+ close() {
+ this.dispatcher.unregisterListener(this, [
+ "GeckoView:WebExtension:PortMessageFromApp",
+ "GeckoView:WebExtension:PortDisconnect",
+ ]);
+ }
+ onPortDisconnect() {
+ this.dispatcher.sendRequest({
+ type: "GeckoView:WebExtension:Disconnect",
+ sender: this.sender,
+ });
+ this.close();
+ }
+ onPortMessage(holder) {
+ this.dispatcher.sendRequest({
+ type: "GeckoView:WebExtension:PortMessage",
+ data: holder.deserialize({}),
+ });
+ }
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:PortMessageFromApp": {
+ const holder = new StructuredCloneHolder(
+ "GeckoView:WebExtension:PortMessageFromApp",
+ null,
+ aData.message
+ );
+ this.messenger.sendPortMessage(this.id, holder);
+ break;
+ }
+
+ case "GeckoView:WebExtension:PortDisconnect": {
+ this.messenger.sendPortDisconnect(this.id);
+ this.close();
+ break;
+ }
+ }
+ }
+}
+
+export class GeckoViewConnection {
+ constructor(sender, target, nativeApp, allowContentMessaging) {
+ this.sender = sender;
+ this.target = target;
+ this.nativeApp = nativeApp;
+ this.allowContentMessaging = allowContentMessaging;
+
+ if (!allowContentMessaging && sender.envType !== "addon_child") {
+ throw new Error(`Unexpected messaging sender: ${JSON.stringify(sender)}`);
+ }
+ }
+
+ get dispatcher() {
+ if (this.sender.envType === "addon_child") {
+ // If this is a WebExtension Page we will have a GeckoSession associated
+ // to it and thus a dispatcher.
+ const dispatcher = GeckoViewUtils.getDispatcherForWindow(
+ this.target.ownerGlobal
+ );
+ if (dispatcher) {
+ return dispatcher;
+ }
+
+ // No dispatcher means this message is coming from a background script,
+ // use the global event handler
+ return lazy.EventDispatcher.instance;
+ } else if (
+ this.sender.envType === "content_child" &&
+ this.allowContentMessaging
+ ) {
+ // If this message came from a content script, send the message to
+ // the corresponding tab messenger so that GeckoSession can pick it
+ // up.
+ return GeckoViewUtils.getDispatcherForWindow(this.target.ownerGlobal);
+ }
+
+ throw new Error(`Uknown sender envType: ${this.sender.envType}`);
+ }
+
+ _sendMessage({ type, portId, data }) {
+ const message = {
+ type,
+ sender: this.sender,
+ data,
+ portId,
+ extensionId: this.sender.id,
+ nativeApp: this.nativeApp,
+ };
+
+ return this.dispatcher.sendRequestForResult(message);
+ }
+
+ sendMessage(data) {
+ return this._sendMessage({
+ type: "GeckoView:WebExtension:Message",
+ data: data.deserialize({}),
+ });
+ }
+
+ onConnect(portId, messenger) {
+ const port = new EmbedderPort(portId, messenger);
+
+ this._sendMessage({
+ type: "GeckoView:WebExtension:Connect",
+ data: {},
+ portId: port.id,
+ });
+
+ return port;
+ }
+}
+
+async function filterPromptPermissions(aPermissions) {
+ if (!aPermissions) {
+ return [];
+ }
+ const promptPermissions = [];
+ for (const permission of aPermissions) {
+ if (!(await lazy.Extension.shouldPromptFor(permission))) {
+ continue;
+ }
+ promptPermissions.push(permission);
+ }
+ return promptPermissions;
+}
+
+// Keep in sync with WebExtension.java
+const FLAG_NONE = 0;
+const FLAG_ALLOW_CONTENT_MESSAGING = 1 << 0;
+
+function exportFlags(aPolicy) {
+ let flags = FLAG_NONE;
+ if (!aPolicy) {
+ return flags;
+ }
+ const { extension } = aPolicy;
+ if (extension.hasPermission("nativeMessagingFromContent")) {
+ flags |= FLAG_ALLOW_CONTENT_MESSAGING;
+ }
+ return flags;
+}
+
+async function exportExtension(aAddon, aPermissions, aSourceURI) {
+ // First, let's make sure the policy is ready if present
+ let policy = WebExtensionPolicy.getByID(aAddon.id);
+ if (policy?.readyPromise) {
+ policy = await policy.readyPromise;
+ }
+ const {
+ amoListingURL,
+ averageRating,
+ blocklistState,
+ creator,
+ description,
+ embedderDisabled,
+ fullDescription,
+ homepageURL,
+ icons,
+ id,
+ incognito,
+ isActive,
+ isBuiltin,
+ isCorrectlySigned,
+ isRecommended,
+ name,
+ optionsType,
+ optionsURL,
+ reviewCount,
+ reviewURL,
+ signedState,
+ sourceURI,
+ temporarilyInstalled,
+ userDisabled,
+ version,
+ } = aAddon;
+ let creatorName = null;
+ let creatorURL = null;
+ if (creator) {
+ const { name, url } = creator;
+ creatorName = name;
+ creatorURL = url;
+ }
+ const openOptionsPageInTab =
+ optionsType === lazy.AddonManager.OPTIONS_TYPE_TAB;
+ const disabledFlags = [];
+ if (userDisabled) {
+ disabledFlags.push("userDisabled");
+ }
+ if (blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ disabledFlags.push("blocklistDisabled");
+ }
+ if (embedderDisabled) {
+ disabledFlags.push("appDisabled");
+ }
+ // Add-ons without an `isCorrectlySigned` property are correctly signed as
+ // they aren't the correct type for signing.
+ if (lazy.AddonSettings.REQUIRE_SIGNING && isCorrectlySigned === false) {
+ disabledFlags.push("signatureDisabled");
+ }
+ if (lazy.AddonManager.checkCompatibility && !aAddon.isCompatible) {
+ disabledFlags.push("appVersionDisabled");
+ }
+ const baseURL = policy ? policy.getURL() : "";
+ const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false;
+ const promptPermissions = aPermissions
+ ? await filterPromptPermissions(aPermissions.permissions)
+ : [];
+
+ let updateDate;
+ try {
+ updateDate = aAddon.updateDate?.toISOString();
+ } catch {
+ // `installDate` is used as a fallback for `updateDate` but only when the
+ // add-on is installed. Before that, `installDate` might be undefined,
+ // which would cause `updateDate` (and `installDate`) to be an "invalid
+ // date".
+ updateDate = null;
+ }
+
+ return {
+ webExtensionId: id,
+ locationURI: aSourceURI != null ? aSourceURI.spec : "",
+ isBuiltIn: isBuiltin,
+ webExtensionFlags: exportFlags(policy),
+ metaData: {
+ amoListingURL,
+ averageRating,
+ baseURL,
+ blocklistState,
+ creatorName,
+ creatorURL,
+ description,
+ disabledFlags,
+ downloadUrl: sourceURI?.displaySpec,
+ enabled: isActive,
+ fullDescription,
+ homepageURL,
+ icons,
+ incognito,
+ isRecommended,
+ name,
+ openOptionsPageInTab,
+ optionsPageURL: optionsURL,
+ origins: aPermissions ? aPermissions.origins : [],
+ privateBrowsingAllowed,
+ promptPermissions,
+ reviewCount,
+ reviewURL,
+ signedState,
+ temporary: temporarilyInstalled,
+ updateDate,
+ version,
+ },
+ };
+}
+
+class ExtensionInstallListener {
+ constructor(aResolve, aInstall, aInstallId) {
+ this.install = aInstall;
+ this.installId = aInstallId;
+ this.resolve = result => {
+ aResolve(result);
+ lazy.EventDispatcher.instance.unregisterListener(this, [
+ "GeckoView:WebExtension:CancelInstall",
+ ]);
+ };
+ lazy.EventDispatcher.instance.registerListener(this, [
+ "GeckoView:WebExtension:CancelInstall",
+ ]);
+ }
+
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:CancelInstall": {
+ const { installId } = aData;
+ if (this.installId !== installId) {
+ return;
+ }
+ this.cancelling = true;
+ let cancelled = false;
+ try {
+ this.install.cancel();
+ cancelled = true;
+ } catch (ex) {
+ // install may have already failed or been cancelled
+ debug`Unable to cancel the install installId ${installId}, Error: ${ex}`;
+ // When we attempt to cancel an install but the cancellation fails for
+ // some reasons (e.g., because it is too late), we need to revert this
+ // boolean property to allow another cancellation to be possible.
+ // Otherwise, events like `onDownloadCancelled` won't resolve and that
+ // will cause problems in the embedder.
+ this.cancelling = false;
+ }
+ aCallback.onSuccess({ cancelled });
+ break;
+ }
+ }
+ }
+
+ onDownloadCancelled(aInstall) {
+ debug`onDownloadCancelled state=${aInstall.state}`;
+ // Do not resolve we were told to CancelInstall,
+ // to prevent racing with that handler.
+ if (!this.cancelling) {
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+ }
+
+ onDownloadFailed(aInstall) {
+ debug`onDownloadFailed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ onDownloadEnded() {
+ // Nothing to do
+ }
+
+ onInstallCancelled(aInstall, aCancelledByUser) {
+ debug`onInstallCancelled state=${aInstall.state} cancelledByUser=${aCancelledByUser}`;
+ // Do not resolve we were told to CancelInstall,
+ // to prevent racing with that handler.
+ if (!this.cancelling) {
+ const { error: installError, state } = aInstall;
+ // An install can be cancelled by the user OR something else, e.g. when
+ // the blocklist prevents the install of a blocked add-on.
+ this.resolve({ installError, state, cancelledByUser: aCancelledByUser });
+ }
+ }
+
+ onInstallFailed(aInstall) {
+ debug`onInstallFailed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ onInstallPostponed(aInstall) {
+ debug`onInstallPostponed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ async onInstallEnded(aInstall, aAddon) {
+ debug`onInstallEnded addonId=${aAddon.id}`;
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ aInstall.sourceURI
+ );
+ this.resolve({ extension });
+ }
+}
+
+class ExtensionPromptObserver {
+ constructor() {
+ Services.obs.addObserver(this, "webextension-permission-prompt");
+ Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+ }
+
+ async permissionPrompt(aInstall, aAddon, aInfo) {
+ const { sourceURI } = aInstall;
+ const { permissions } = aInfo;
+ const extension = await exportExtension(aAddon, permissions, sourceURI);
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:InstallPrompt",
+ extension,
+ });
+
+ if (response.allow) {
+ aInfo.resolve();
+ } else {
+ aInfo.reject();
+ }
+ }
+
+ async optionalPermissionPrompt(aExtensionId, aPermissions, resolve) {
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:OptionalPrompt",
+ extensionId: aExtensionId,
+ permissions: aPermissions,
+ });
+ resolve(response.allow);
+ }
+
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+
+ switch (aTopic) {
+ case "webextension-permission-prompt": {
+ const { info } = aSubject.wrappedJSObject;
+ const { addon, install } = info;
+ this.permissionPrompt(install, addon, info);
+ break;
+ }
+ case "webextension-optional-permission-prompt": {
+ const { id, permissions, resolve } = aSubject.wrappedJSObject;
+ this.optionalPermissionPrompt(id, permissions, resolve);
+ break;
+ }
+ }
+ }
+}
+
+class AddonInstallObserver {
+ constructor() {
+ Services.obs.addObserver(this, "addon-install-failed");
+ }
+
+ async onInstallationFailed(aAddon, aAddonName, aError) {
+ // aAddon could be null if we have a network error where we can't download the xpi file.
+ // aAddon could also be a valid object without an ID when the xpi file is corrupt.
+ let extension = null;
+ if (aAddon?.id) {
+ extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ }
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstallationFailed",
+ extension,
+ addonName: aAddonName,
+ error: aError,
+ });
+ }
+
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+ switch (aTopic) {
+ case "addon-install-failed": {
+ aSubject.wrappedJSObject.installs.forEach(install => {
+ const { addon, error, name } = install;
+ // For some errors, we have a valid `addon` but not the `name` set on
+ // the `install` object yet so we check both here.
+ const addonName = name || addon?.name;
+
+ this.onInstallationFailed(addon, addonName, error);
+ });
+ break;
+ }
+ }
+ }
+}
+
+new ExtensionPromptObserver();
+new AddonInstallObserver();
+
+class AddonManagerListener {
+ constructor() {
+ lazy.AddonManager.addAddonListener(this);
+ // Some extension properties are not going to be available right away after the extension
+ // have been installed (e.g. in particular metaData.optionsPageURL), the GeckoView event
+ // dispatched from onExtensionReady listener will be providing updated extension metadata to
+ // the GeckoView side when it is actually going to be available.
+ this.onExtensionReady = this.onExtensionReady.bind(this);
+ lazy.Management.on("ready", this.onExtensionReady);
+ }
+
+ async onExtensionReady(name, extInstance) {
+ // In xpcshell tests there wil be test extensions that trigger this event while the
+ // AddonManager has not been started at all, on the contrary on a regular browser
+ // instance the AddonManager is expected to be already fully started for an extension
+ // for the extension to be able to reach the "ready" state, and so we just silently
+ // early exit here if the AddonManager is not ready.
+ if (!lazy.AddonManager.isReady) {
+ return;
+ }
+
+ debug`onExtensionReady ${extInstance.id}`;
+
+ const addonWrapper = await lazy.AddonManager.getAddonByID(extInstance.id);
+ if (!addonWrapper) {
+ return;
+ }
+
+ const extension = await exportExtension(
+ addonWrapper,
+ addonWrapper.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnReady",
+ extension,
+ });
+ }
+
+ async onDisabling(aAddon) {
+ debug`onDisabling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabling",
+ extension,
+ });
+ }
+
+ async onDisabled(aAddon) {
+ debug`onDisabled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabled",
+ extension,
+ });
+ }
+
+ async onEnabling(aAddon) {
+ debug`onEnabling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnEnabling",
+ extension,
+ });
+ }
+
+ async onEnabled(aAddon) {
+ debug`onEnabled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnEnabled",
+ extension,
+ });
+ }
+
+ async onUninstalling(aAddon) {
+ debug`onUninstalling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnUninstalling",
+ extension,
+ });
+ }
+
+ async onUninstalled(aAddon) {
+ debug`onUninstalled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnUninstalled",
+ extension,
+ });
+ }
+
+ async onInstalling(aAddon) {
+ debug`onInstalling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstalling",
+ extension,
+ });
+ }
+
+ async onInstalled(aAddon) {
+ debug`onInstalled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstalled",
+ extension,
+ });
+ }
+}
+
+new AddonManagerListener();
+
+class ExtensionProcessListener {
+ constructor() {
+ this.onExtensionProcessCrash = this.onExtensionProcessCrash.bind(this);
+ lazy.Management.on("extension-process-crash", this.onExtensionProcessCrash);
+
+ lazy.EventDispatcher.instance.registerListener(this, [
+ "GeckoView:WebExtension:EnableProcessSpawning",
+ "GeckoView:WebExtension:DisableProcessSpawning",
+ ]);
+ }
+
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:EnableProcessSpawning": {
+ debug`Extension process crash -> re-enable process spawning`;
+ lazy.ExtensionProcessCrashObserver.enableProcessSpawning();
+ break;
+ }
+ }
+ }
+
+ async onExtensionProcessCrash(name, { childID, processSpawningDisabled }) {
+ debug`Extension process crash -> childID=${childID} processSpawningDisabled=${processSpawningDisabled}`;
+
+ // When an extension process has crashed too many times, Gecko will set
+ // `processSpawningDisabled` and no longer allow the extension process
+ // spawning. We only want to send a request to the embedder when we are
+ // disabling the process spawning. If process spawning is still enabled
+ // then we short circuit and don't notify the embedder.
+ if (!processSpawningDisabled) {
+ return;
+ }
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabledProcessSpawning",
+ });
+ }
+}
+
+new ExtensionProcessListener();
+
+class MobileWindowTracker extends EventEmitter {
+ constructor() {
+ super();
+ this._topWindow = null;
+ this._topNonPBWindow = null;
+ }
+
+ get topWindow() {
+ if (this._topWindow) {
+ return this._topWindow.get();
+ }
+ return null;
+ }
+
+ get topNonPBWindow() {
+ if (this._topNonPBWindow) {
+ return this._topNonPBWindow.get();
+ }
+ return null;
+ }
+
+ setTabActive(aWindow, aActive) {
+ const { browser, tab: nativeTab, docShell } = aWindow;
+ nativeTab.active = aActive;
+
+ if (aActive) {
+ this._topWindow = Cu.getWeakReference(aWindow);
+ const isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
+ if (!isPrivate) {
+ this._topNonPBWindow = this._topWindow;
+ }
+ this.emit("tab-activated", {
+ windowId: docShell.outerWindowID,
+ tabId: nativeTab.id,
+ isPrivate,
+ nativeTab,
+ });
+ }
+ }
+}
+
+export var mobileWindowTracker = new MobileWindowTracker();
+
+async function updatePromptHandler(aInfo) {
+ const oldPerms = aInfo.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, let it proceed
+ return;
+ }
+
+ const newPerms = aInfo.addon.userPermissions;
+
+ const difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
+
+ // We only care about permissions that we can prompt the user for
+ const newPermissions = await filterPromptPermissions(difference.permissions);
+ const { origins: newOrigins } = difference;
+
+ // If there are no new permissions, just proceed
+ if (!newOrigins.length && !newPermissions.length) {
+ return;
+ }
+
+ const currentlyInstalled = await exportExtension(
+ aInfo.existingAddon,
+ oldPerms
+ );
+ const updatedExtension = await exportExtension(aInfo.addon, newPerms);
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:UpdatePrompt",
+ currentlyInstalled,
+ updatedExtension,
+ newPermissions,
+ newOrigins,
+ });
+
+ if (!response.allow) {
+ throw new Error("Extension update rejected.");
+ }
+}
+
+export var GeckoViewWebExtension = {
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+
+ switch (aTopic) {
+ case "testing-installed-addon":
+ case "testing-uninstalled-addon": {
+ // We pretend devtools installed/uninstalled this addon so we don't
+ // have to add an API just for internal testing.
+ // TODO: assert this is under a test
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:DebuggerListUpdated",
+ });
+ break;
+ }
+
+ case "devtools-installed-addon": {
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:DebuggerListUpdated",
+ });
+ break;
+ }
+ }
+ },
+
+ async extensionById(aId) {
+ const addon = await lazy.AddonManager.getAddonByID(aId);
+ if (!addon) {
+ debug`Could not find extension with id=${aId}`;
+ return null;
+ }
+ return addon;
+ },
+
+ async ensureBuiltIn(aUri, aId) {
+ await lazy.AddonManager.readyPromise;
+ // Although the add-on is privileged in practice due to it being installed
+ // as a built-in extension, we pass isPrivileged=false since the exact flag
+ // doesn't matter as we are only using ExtensionData to read the version.
+ const extensionData = new lazy.ExtensionData(aUri, false);
+ const [extensionVersion, extension] = await Promise.all([
+ extensionData.getExtensionVersionWithoutValidation(),
+ this.extensionById(aId),
+ ]);
+
+ if (!extension || extensionVersion != extension.version) {
+ return this.installBuiltIn(aUri);
+ }
+
+ const exported = await exportExtension(
+ extension,
+ extension.userPermissions,
+ aUri
+ );
+ return { extension: exported };
+ },
+
+ async installBuiltIn(aUri) {
+ await lazy.AddonManager.readyPromise;
+ const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec);
+ const exported = await exportExtension(addon, addon.userPermissions, aUri);
+ return { extension: exported };
+ },
+
+ async installWebExtension(aInstallId, aUri, installMethod) {
+ const install = await lazy.AddonManager.getInstallForURL(aUri.spec, {
+ telemetryInfo: {
+ source: "geckoview-app",
+ method: installMethod || undefined,
+ },
+ });
+ const promise = new Promise(resolve => {
+ install.addListener(
+ new ExtensionInstallListener(resolve, install, aInstallId)
+ );
+ });
+
+ lazy.AddonManager.installAddonFromAOM(null, aUri, install);
+
+ return promise;
+ },
+
+ async setPrivateBrowsingAllowed(aId, aAllowed) {
+ if (aAllowed) {
+ await lazy.ExtensionPermissions.add(aId, PRIVATE_BROWSING_PERMISSION);
+ } else {
+ await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMISSION);
+ }
+
+ // Reload the extension if it is already enabled. This ensures any change
+ // on the private browsing permission is properly handled.
+ const addon = await this.extensionById(aId);
+ if (addon.isActive) {
+ await addon.reload();
+ }
+
+ return exportExtension(addon, addon.userPermissions, /* aSourceURI */ null);
+ },
+
+ async uninstallWebExtension(aId) {
+ const extension = await this.extensionById(aId);
+ if (!extension) {
+ throw new Error(`Could not find an extension with id='${aId}'.`);
+ }
+
+ return extension.uninstall();
+ },
+
+ async browserActionClick(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ return undefined;
+ }
+
+ const browserAction = this.browserActions.get(policy.extension);
+ if (!browserAction) {
+ return undefined;
+ }
+
+ return browserAction.triggerClickOrPopup();
+ },
+
+ async pageActionClick(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ return undefined;
+ }
+
+ const pageAction = this.pageActions.get(policy.extension);
+ if (!pageAction) {
+ return undefined;
+ }
+
+ return pageAction.triggerClickOrPopup();
+ },
+
+ async actionDelegateAttached(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ debug`Could not find extension with id=${aId}`;
+ return;
+ }
+
+ const { extension } = policy;
+
+ const browserAction = this.browserActions.get(extension);
+ if (browserAction) {
+ // Send information about this action to the delegate
+ browserAction.updateOnChange(null);
+ }
+
+ const pageAction = this.pageActions.get(extension);
+ if (pageAction) {
+ pageAction.updateOnChange(null);
+ }
+ },
+
+ async enableWebExtension(aId, aSource) {
+ const extension = await this.extensionById(aId);
+ if (aSource === "user") {
+ await extension.enable();
+ } else if (aSource === "app") {
+ await extension.setEmbedderDisabled(false);
+ }
+ return exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ );
+ },
+
+ async disableWebExtension(aId, aSource) {
+ const extension = await this.extensionById(aId);
+ if (aSource === "user") {
+ await extension.disable();
+ } else if (aSource === "app") {
+ await extension.setEmbedderDisabled(true);
+ }
+ return exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ );
+ },
+
+ /**
+ * @return A promise resolved with either an AddonInstall object if an update
+ * is available or null if no update is found.
+ */
+ checkForUpdate(aAddon) {
+ return new Promise(resolve => {
+ const listener = {
+ onUpdateAvailable(aAddon, install) {
+ install.promptHandler = updatePromptHandler;
+ resolve(install);
+ },
+ onNoUpdateAvailable() {
+ resolve(null);
+ },
+ };
+ aAddon.findUpdates(
+ listener,
+ lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+ },
+
+ async updateWebExtension(aId) {
+ // Refresh the cached metadata when necessary. This allows us to always
+ // export relatively recent metadata to the embedder.
+ if (lazy.AddonRepository.isMetadataStale()) {
+ // We use a promise to avoid more than one call to `backgroundUpdateCheck()`
+ // when `updateWebExtension()` is called for multiple add-ons in parallel.
+ if (!this._promiseAddonRepositoryUpdate) {
+ this._promiseAddonRepositoryUpdate =
+ lazy.AddonRepository.backgroundUpdateCheck().finally(() => {
+ this._promiseAddonRepositoryUpdate = null;
+ });
+ }
+ await this._promiseAddonRepositoryUpdate;
+ }
+
+ // Early-return when extension updates are disabled.
+ if (!lazy.AddonManager.updateEnabled) {
+ return null;
+ }
+
+ const extension = await this.extensionById(aId);
+
+ const install = await this.checkForUpdate(extension);
+ if (!install) {
+ return null;
+ }
+ const promise = new Promise(resolve => {
+ install.addListener(new ExtensionInstallListener(resolve));
+ });
+ install.install();
+ return promise;
+ },
+
+ validateBuiltInLocation(aLocationUri, aCallback) {
+ let uri;
+ try {
+ uri = Services.io.newURI(aLocationUri);
+ } catch (ex) {
+ aCallback.onError(`Could not parse uri: ${aLocationUri}. Error: ${ex}`);
+ return null;
+ }
+
+ if (uri.scheme !== "resource" || uri.host !== "android") {
+ aCallback.onError(`Only resource://android/... URIs are allowed.`);
+ return null;
+ }
+
+ if (uri.fileName !== "") {
+ aCallback.onError(
+ `This URI does not point to a folder. Note: folders URIs must end with a "/".`
+ );
+ return null;
+ }
+
+ return uri;
+ },
+
+ /* eslint-disable complexity */
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:BrowserAction:Click": {
+ const popupUrl = await this.browserActionClick(aData.extensionId);
+ aCallback.onSuccess(popupUrl);
+ break;
+ }
+ case "GeckoView:PageAction:Click": {
+ const popupUrl = await this.pageActionClick(aData.extensionId);
+ aCallback.onSuccess(popupUrl);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuClick": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuShow": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuHide": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+
+ case "GeckoView:ActionDelegate:Attached": {
+ this.actionDelegateAttached(aData.extensionId);
+ break;
+ }
+
+ case "GeckoView:WebExtension:Get": {
+ const extension = await this.extensionById(aData.extensionId);
+ if (!extension) {
+ aCallback.onError(
+ `Could not find extension with id: ${aData.extensionId}`
+ );
+ return;
+ }
+
+ aCallback.onSuccess({
+ extension: await exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ ),
+ });
+ break;
+ }
+
+ case "GeckoView:WebExtension:SetPBAllowed": {
+ const { extensionId, allowed } = aData;
+ try {
+ const extension = await this.setPrivateBrowsingAllowed(
+ extensionId,
+ allowed
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Install": {
+ const { locationUri, installId, installMethod } = aData;
+ let uri;
+ try {
+ uri = Services.io.newURI(locationUri);
+ } catch (ex) {
+ aCallback.onError(`Could not parse uri: ${locationUri}`);
+ return;
+ }
+
+ try {
+ const result = await this.installWebExtension(
+ installId,
+ uri,
+ installMethod
+ );
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:EnsureBuiltIn": {
+ const { locationUri, webExtensionId } = aData;
+ const uri = this.validateBuiltInLocation(locationUri, aCallback);
+ if (!uri) {
+ return;
+ }
+
+ try {
+ const result = await this.ensureBuiltIn(uri, webExtensionId);
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:InstallBuiltIn": {
+ const uri = this.validateBuiltInLocation(aData.locationUri, aCallback);
+ if (!uri) {
+ return;
+ }
+
+ try {
+ const result = await this.installBuiltIn(uri);
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:Uninstall": {
+ try {
+ await this.uninstallWebExtension(aData.webExtensionId);
+ aCallback.onSuccess();
+ } catch (ex) {
+ debug`Failed uninstall ${ex}`;
+ aCallback.onError(
+ `This extension cannot be uninstalled. Error: ${ex}.`
+ );
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Enable": {
+ try {
+ const { source, webExtensionId } = aData;
+ if (source !== "user" && source !== "app") {
+ throw new Error("Illegal source parameter");
+ }
+ const extension = await this.enableWebExtension(
+ webExtensionId,
+ source
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ debug`Failed enable ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Disable": {
+ try {
+ const { source, webExtensionId } = aData;
+ if (source !== "user" && source !== "app") {
+ throw new Error("Illegal source parameter");
+ }
+ const extension = await this.disableWebExtension(
+ webExtensionId,
+ source
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ debug`Failed disable ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:List": {
+ try {
+ await lazy.AddonManager.readyPromise;
+ const addons = await lazy.AddonManager.getAddonsByTypes([
+ "extension",
+ ]);
+ const extensions = await Promise.all(
+ addons.map(addon =>
+ exportExtension(addon, addon.userPermissions, null)
+ )
+ );
+
+ aCallback.onSuccess({ extensions });
+ } catch (ex) {
+ debug`Failed list ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Update": {
+ try {
+ const { webExtensionId } = aData;
+ const result = await this.updateWebExtension(webExtensionId);
+ if (result === null || result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Failed update ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+ }
+ },
+};
+
+// WeakMap[Extension -> BrowserAction]
+GeckoViewWebExtension.browserActions = new WeakMap();
+// WeakMap[Extension -> PageAction]
+GeckoViewWebExtension.pageActions = new WeakMap();