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/. */
import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
const PRIVATE_BROWSING_PERMS = {
permissions: [PRIVATE_BROWSING_PERM_NAME],
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) {
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;
}
function normalizePermissions(perms) {
if (perms?.permissions) {
perms = { ...perms };
perms.permissions = perms.permissions.filter(
perm => !perm.startsWith("internal:")
);
}
return perms;
}
async function exportExtension(aAddon, 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_BLOCKED) {
disabledFlags.push("blocklistDisabled");
} else if (blocklistState === Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
disabledFlags.push("softBlocklistDisabled");
}
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() : "";
let privateBrowsingAllowed;
if (policy) {
privateBrowsingAllowed = policy.privateBrowsingAllowed;
} else {
const { permissions } = await lazy.ExtensionPermissions.get(aAddon.id);
privateBrowsingAllowed = permissions.includes(PRIVATE_BROWSING_PERM_NAME);
}
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;
}
const requiredPermissions = aAddon.userPermissions?.permissions ?? [];
const requiredOrigins = aAddon.userPermissions?.origins ?? [];
const optionalPermissions = aAddon.optionalPermissions?.permissions ?? [];
const optionalOrigins = aAddon.optionalOriginsNormalized;
const grantedPermissions = normalizePermissions(
await lazy.ExtensionPermissions.get(id)
);
const grantedOptionalPermissions = grantedPermissions?.permissions ?? [];
const grantedOptionalOrigins = grantedPermissions?.origins ?? [];
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,
privateBrowsingAllowed,
reviewCount,
reviewURL,
signedState,
temporary: temporarilyInstalled,
updateDate,
version,
requiredPermissions,
requiredOrigins,
optionalPermissions,
optionalOrigins,
grantedOptionalPermissions,
grantedOptionalOrigins,
},
};
}
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, aInstall.sourceURI);
this.resolve({ extension });
}
}
class ExtensionPromptObserver {
constructor() {
Services.obs.addObserver(this, "webextension-permission-prompt");
Services.obs.addObserver(this, "webextension-optional-permission-prompt");
}
async permissionPromptRequest(aInstall, aAddon, aInfo) {
const { sourceURI } = aInstall;
const { permissions } = aInfo;
const extension = await exportExtension(aAddon, sourceURI);
const response = await lazy.EventDispatcher.instance.sendRequestForResult({
type: "GeckoView:WebExtension:InstallPrompt",
extension,
permissions: await filterPromptPermissions(permissions.permissions),
origins: permissions.origins,
});
if (response.allow) {
if (response.privateBrowsingAllowed) {
await lazy.ExtensionPermissions.add(aAddon.id, PRIVATE_BROWSING_PERMS);
} else {
await lazy.ExtensionPermissions.remove(
aAddon.id,
PRIVATE_BROWSING_PERMS
);
}
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) {
debug`observe ${aTopic}`;
switch (aTopic) {
case "webextension-permission-prompt": {
const { info } = aSubject.wrappedJSObject;
const { addon, install } = info;
this.permissionPromptRequest(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, /* aSourceURI */ null);
}
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnInstallationFailed",
extension,
addonId: aAddon?.id,
addonName: aAddonName,
addonVersion: aAddon?.version,
error: aError,
});
}
observe(aSubject, aTopic) {
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);
lazy.Management.on("change-permissions", this.onOptionalPermissionsChanged);
}
async onOptionalPermissionsChanged(type, { extensionId }) {
// 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;
}
const addon = await lazy.AddonManager.getAddonByID(extensionId);
if (!addon) {
return;
}
const extension = await exportExtension(addon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnOptionalPermissionsChanged",
extension,
});
}
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,
/* aSourceURI */ null
);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnReady",
extension,
});
}
async onDisabling(aAddon) {
debug`onDisabling ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnDisabling",
extension,
});
}
async onDisabled(aAddon) {
debug`onDisabled ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnDisabled",
extension,
});
}
async onEnabling(aAddon) {
debug`onEnabling ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnEnabling",
extension,
});
}
async onEnabled(aAddon) {
debug`onEnabled ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnEnabled",
extension,
});
}
async onUninstalling(aAddon) {
debug`onUninstalling ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnUninstalling",
extension,
});
}
async onUninstalled(aAddon) {
debug`onUninstalled ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnUninstalled",
extension,
});
}
async onInstalling(aAddon) {
debug`onInstalling ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* aSourceURI */ null);
lazy.EventDispatcher.instance.sendRequest({
type: "GeckoView:WebExtension:OnInstalling",
extension,
});
}
async onInstalled(aAddon) {
debug`onInstalled ${aAddon.id}`;
const extension = await exportExtension(aAddon, /* 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) {
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,
/* aSourceURI */ null
);
const updatedExtension = await exportExtension(
aInfo.addon,
/* aSourceURI */ null
);
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) {
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, aUri);
return { extension: exported };
},
async installBuiltIn(aUri) {
await lazy.AddonManager.readyPromise;
const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec);
const exported = await exportExtension(addon, 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_PERMS);
} else {
await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMS);
}
// 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, /* 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, /* 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, /* 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, /* 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:AddOptionalPermissions": {
const { extensionId, permissions, origins } = aData;
try {
const addon = await this.extensionById(extensionId);
const normalized = lazy.ExtensionPermissions.normalizeOptional(
{
permissions,
origins,
},
addon.optionalPermissions
);
const policy = WebExtensionPolicy.getByID(addon.id);
await lazy.ExtensionPermissions.add(
extensionId,
normalized,
policy?.extension
);
const extension = await exportExtension(addon, /* aSourceURI */ null);
aCallback.onSuccess({ extension });
} catch (ex) {
aCallback.onError(`Unexpected error: ${ex}`);
}
break;
}
case "GeckoView:WebExtension:RemoveOptionalPermissions": {
const { extensionId, permissions, origins } = aData;
try {
const addon = await this.extensionById(extensionId);
const normalized = lazy.ExtensionPermissions.normalizeOptional(
{ permissions, origins },
addon.optionalPermissions
);
const policy = WebExtensionPolicy.getByID(addon.id);
await lazy.ExtensionPermissions.remove(
addon.id,
normalized,
policy?.extension
);
const extension = await exportExtension(addon, /* aSourceURI */ null);
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, /* aSourceURI */ 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();