From 8252e45d0fc8781cfb108d18a308acc0775e5b55 Mon Sep 17 00:00:00 2001 From: Alex Kontos Date: Wed, 27 Jul 2022 11:31:47 +0100 Subject: [PATCH] feat: implement privileged extension support Also includes: * feat: improve support for bootstrapped extensions Added support for custom preference pages. * fix: incorrect loading order for bootstrap loader * fix: BootstrapLoader (cherry picked from commit eb40811e464688c7d2fc58a4330272dde1ec7937) --- browser/components/BrowserGlue.sys.mjs | 6 + .../components/extensions/ext-browser.json | 6 + browser/components/extensions/jar.mn | 1 + .../extensions/parent/ext-legacy.js | 469 +++++ browser/components/extensions/schemas/jar.mn | 1 + .../components/extensions/schemas/legacy.json | 43 + .../mozapps/extensions/AddonManager.sys.mjs | 4 +- .../extensions/internal/XPIDatabase.sys.mjs | 5 +- .../extensions/common/BootstrapLoader.jsm | 458 +++++ .../extensions/common/ChromeManifest.jsm | 363 ++++ .../extensions/common/ExtensionSupport.jsm | 410 ++++ .../browser/extensions/common/IOUtils.jsm | 143 ++ .../browser/extensions/common/Overlays.jsm | 599 ++++++ .../extensions/common/RDFDataSource.jsm | 1724 +++++++++++++++++ .../common/RDFManifestConverter.jsm | 138 ++ .../extensions/common/iteratorUtils.jsm | 132 ++ waterfox/browser/extensions/common/moz.build | 21 + .../common/test/xpcshell/.eslintrc.js | 9 + .../test/xpcshell/data/BootstrapMonitor.jsm | 42 + .../common/test/xpcshell/head_addons.js | 1716 ++++++++++++++++ .../common/test/xpcshell/test_bootstrap.js | 1174 +++++++++++ .../test/xpcshell/test_bootstrap_const.js | 29 + .../test/xpcshell/test_bootstrap_globals.js | 65 + .../test_bootstrapped_chrome_manifest.js | 54 + .../test/xpcshell/test_invalid_install_rdf.js | 167 ++ .../common/test/xpcshell/test_manifest.js | 806 ++++++++ .../test/xpcshell/test_manifest_locales.js | 140 ++ .../common/test/xpcshell/xpcshell.ini | 13 + waterfox/browser/moz.build | 2 + 29 files changed, 8737 insertions(+), 3 deletions(-) create mode 100644 browser/components/extensions/parent/ext-legacy.js create mode 100644 browser/components/extensions/schemas/legacy.json create mode 100644 waterfox/browser/extensions/common/BootstrapLoader.jsm create mode 100644 waterfox/browser/extensions/common/ChromeManifest.jsm create mode 100644 waterfox/browser/extensions/common/ExtensionSupport.jsm create mode 100644 waterfox/browser/extensions/common/IOUtils.jsm create mode 100644 waterfox/browser/extensions/common/Overlays.jsm create mode 100644 waterfox/browser/extensions/common/RDFDataSource.jsm create mode 100644 waterfox/browser/extensions/common/RDFManifestConverter.jsm create mode 100644 waterfox/browser/extensions/common/iteratorUtils.jsm create mode 100644 waterfox/browser/extensions/common/moz.build create mode 100644 waterfox/browser/extensions/common/test/xpcshell/.eslintrc.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/data/BootstrapMonitor.jsm create mode 100644 waterfox/browser/extensions/common/test/xpcshell/head_addons.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_bootstrap.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_bootstrap_const.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_bootstrap_globals.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_bootstrapped_chrome_manifest.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_invalid_install_rdf.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_manifest.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/test_manifest_locales.js create mode 100644 waterfox/browser/extensions/common/test/xpcshell/xpcshell.ini diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index bde641f7112a..539b3972e331 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -229,6 +229,12 @@ BrowserGlue.prototype = { // nsIObserver implementation observe: async function BG_observe(subject, topic, data) { switch (topic) { + case "app-startup": + const { BootstrapLoader } = ChromeUtils.import( + "resource:///modules/BootstrapLoader.jsm" + ); + AddonManager.addExternalExtensionLoader(BootstrapLoader); + break; case "notifications-open-settings": this._openPreferences("privacy-permissions"); break; diff --git a/browser/components/extensions/ext-browser.json b/browser/components/extensions/ext-browser.json index d5f3e0a500f2..4a2bd9a13cea 100644 --- a/browser/components/extensions/ext-browser.json +++ b/browser/components/extensions/ext-browser.json @@ -94,6 +94,12 @@ "scopes": ["addon_parent"], "paths": [["identity"]] }, + "legacy": { + "url": "chrome://browser/content/parent/ext-legacy.js", + "schema": "chrome://browser/content/schemas/legacy.json", + "scopes": ["addon_parent"], + "manifest": ["legacy"] + }, "menusChild": { "schema": "chrome://browser/content/schemas/menus_child.json", "scopes": ["addon_child", "content_child", "devtools_child"] diff --git a/browser/components/extensions/jar.mn b/browser/components/extensions/jar.mn index 1bb336b45b90..1e2f04caeb48 100644 --- a/browser/components/extensions/jar.mn +++ b/browser/components/extensions/jar.mn @@ -17,6 +17,7 @@ browser.jar: content/browser/parent/ext-devtools-panels.js (parent/ext-devtools-panels.js) content/browser/parent/ext-find.js (parent/ext-find.js) content/browser/parent/ext-history.js (parent/ext-history.js) + content/browser/parent/ext-legacy.js (parent/ext-legacy.js) content/browser/parent/ext-menus.js (parent/ext-menus.js) content/browser/parent/ext-normandyAddonStudy.js (parent/ext-normandyAddonStudy.js) content/browser/parent/ext-omnibox.js (parent/ext-omnibox.js) diff --git a/browser/components/extensions/parent/ext-legacy.js b/browser/components/extensions/parent/ext-legacy.js new file mode 100644 index 000000000000..593dbb336f59 --- /dev/null +++ b/browser/components/extensions/parent/ext-legacy.js @@ -0,0 +1,469 @@ +/* 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/. */ + +ChromeUtils.defineModuleGetter( + this, + "ChromeManifest", + "resource:///modules/ChromeManifest.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionSupport", + "resource:///modules/ExtensionSupport.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Overlays", + "resource:///modules/Overlays.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "XPIInternal", + "resource://gre/modules/addons/XPIProvider.jsm" +); + +Cu.importGlobalProperties(["fetch"]); + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); + +XPCOMUtils.defineLazyGetter(this, "BOOTSTRAP_REASONS", () => { + const { XPIProvider } = ChromeUtils.import( + "resource://gre/modules/addons/XPIProvider.jsm" + ); + return XPIProvider.BOOTSTRAP_REASONS; +}); + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +var logger = Log.repository.getLogger("addons.bootstrap"); + +let bootstrapScopes = new Map(); +let cachedParams = new Map(); + +Services.obs.addObserver(() => { + for (let [id, scope] of bootstrapScopes.entries()) { + if (ExtensionSupport.loadedBootstrapExtensions.has(id)) { + scope.shutdown( + { ...cachedParams.get(id) }, + BOOTSTRAP_REASONS.APP_SHUTDOWN + ); + } + } +}, "quit-application-granted"); + +var { ExtensionError } = ExtensionUtils; + +this.legacy = class extends ExtensionAPI { + async onManifestEntry(entryName) { + if (this.extension.manifest.legacy) { + if (this.extension.manifest.legacy.type == "bootstrap") { + await this.registerBootstrapped(); + } else { + await this.registerNonBootstrapped(); + } + } + } + + // This function is for non-bootstrapped add-ons. + + async registerNonBootstrapped() { + this.extension.legacyLoaded = true; + + let state = { + id: this.extension.id, + pendingOperation: null, + version: this.extension.version, + }; + if (ExtensionSupport.loadedLegacyExtensions.has(this.extension.id)) { + state = ExtensionSupport.loadedLegacyExtensions.get(this.extension.id); + let versionComparison = Services.vc.compare( + this.extension.version, + state.version + ); + if (versionComparison != 0) { + if (versionComparison > 0) { + state.pendingOperation = "upgrade"; + ExtensionSupport.loadedLegacyExtensions.notifyObservers(state); + } else if (versionComparison < 0) { + state.pendingOperation = "downgrade"; + ExtensionSupport.loadedLegacyExtensions.notifyObservers(state); + } + + // Forget any cached files we might've had from another version of this extension. + Services.obs.notifyObservers(null, "startupcache-invalidate"); + } + console.log( + `Legacy WebExtension ${ + this.extension.id + } has already been loaded in this run, refusing to do so again. Please restart.` + ); + return; + } + + ExtensionSupport.loadedLegacyExtensions.set(this.extension.id, state); + if (this.extension.startupReason == "ADDON_INSTALL") { + // Usually, sideloaded extensions are disabled when they first appear, + // but to run calendar tests, we disable this. + let scope = XPIInternal.XPIStates.findAddon(this.extension.id).location + .scope; + let autoDisableScopes = Services.prefs.getIntPref( + "extensions.autoDisableScopes" + ); + + // If the extension was just installed from the distribution folder, + // it's in the profile extensions folder. We don't want to disable it. + let isDistroAddon = Services.prefs.getBoolPref( + "extensions.installedDistroAddon." + this.extension.id, + false + ); + + if (!isDistroAddon && scope & autoDisableScopes) { + state.pendingOperation = "install"; + console.log( + `Legacy WebExtension ${ + this.extension.id + } loading for other reason than startup (${ + this.extension.startupReason + }), refusing to load immediately.` + ); + ExtensionSupport.loadedLegacyExtensions.notifyObservers(state); + + // Forget any cached files we might've had if this extension was previously installed. + Services.obs.notifyObservers(null, "startupcache-invalidate"); + return; + } + } + if (this.extension.startupReason == "ADDON_ENABLE") { + state.pendingOperation = "enable"; + console.log( + `Legacy WebExtension ${ + this.extension.id + } loading for other reason than startup (${ + this.extension.startupReason + }), refusing to load immediately.` + ); + ExtensionSupport.loadedLegacyExtensions.notifyObservers(state); + return; + } + + let extensionRoot; + if (this.extension.rootURI instanceof Ci.nsIJARURI) { + extensionRoot = this.extension.rootURI.JARFile.QueryInterface( + Ci.nsIFileURL + ).file; + console.log("Loading packed extension from", extensionRoot.path); + } else { + extensionRoot = this.extension.rootURI.QueryInterface(Ci.nsIFileURL).file; + console.log("Loading unpacked extension from", extensionRoot.path); + } + + // Have Gecko do as much loading as is still possible + try { + Cc["@mozilla.org/component-manager-extra;1"] + .getService(Ci.nsIComponentManagerExtra) + .addLegacyExtensionManifestLocation(extensionRoot); + } catch (e) { + throw new ExtensionError(e.message, e.fileName, e.lineNumber); + } + + // Load chrome.manifest + let appinfo = Services.appinfo; + let options = { + application: appinfo.ID, + appversion: appinfo.version, + platformversion: appinfo.platformVersion, + os: appinfo.OS, + osversion: Services.sysinfo.getProperty("version"), + abi: appinfo.XPCOMABI, + }; + let loader = async filename => { + let url = this.extension.getURL(filename); + return fetch(url).then(response => response.text()); + }; + let chromeManifest = new ChromeManifest(loader, options); + await chromeManifest.parse("chrome.manifest"); + + // Load preference files + console.log("Loading add-on preferences from ", extensionRoot.path); + ExtensionSupport.loadAddonPrefs(extensionRoot); + + // Fire profile-after-change notifications, because we are past that event by now + console.log("Firing profile-after-change listeners for", this.extension.id); + let profileAfterChange = chromeManifest.category.get( + "profile-after-change" + ); + for (let contractid of profileAfterChange.values()) { + let service = contractid.startsWith("service,"); + let instance; + try { + if (service) { + instance = Cc[contractid.substr(8)].getService(Ci.nsIObserver); + } else { + instance = Cc[contractid].createInstance(Ci.nsIObserver); + } + + instance.observe(null, "profile-after-change", null); + } catch (e) { + console.error( + "Error firing profile-after-change listener for", + contractid + ); + } + } + + // Overlays.load must only be called once per window per extension. + // We use this WeakSet to remember all windows we've already seen. + let seenDocuments = new WeakSet(); + + // Listen for new windows to overlay. + let documentObserver = { + observe(doc) { + if ( + ExtensionCommon.instanceOf(doc, "HTMLDocument") && + !seenDocuments.has(doc) + ) { + seenDocuments.add(doc); + Overlays.load(chromeManifest, doc.defaultView); + } + }, + }; + Services.obs.addObserver(documentObserver, "chrome-document-interactive"); + + // Add overlays to all existing windows. + getAllWindows().forEach(win => { + if ( + ["interactive", "complete"].includes(win.document.readyState) && + !seenDocuments.has(win.document) + ) { + seenDocuments.add(win.document); + Overlays.load(chromeManifest, win); + } + }); + + this.extension.callOnClose({ + close: () => { + Services.obs.removeObserver( + documentObserver, + "chrome-document-interactive" + ); + }, + }); + } + + // The following functions are for bootstrapped add-ons. + + async registerBootstrapped() { + let oldParams = cachedParams.get(this.extension.id); + let params = { + id: this.extension.id, + version: this.extension.version, + resourceURI: Services.io.newURI(this.extension.resourceURL), + installPath: this.extensionFile.path, + }; + cachedParams.set(this.extension.id, { ...params }); + + if ( + oldParams && + ["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes( + this.extension.startupReason + ) + ) { + params.oldVersion = oldParams.version; + } + + let scope = await this.loadScope(); + bootstrapScopes.set(this.extension.id, scope); + + if ( + ["ADDON_INSTALL", "ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes( + this.extension.startupReason + ) + ) { + scope.install(params, BOOTSTRAP_REASONS[this.extension.startupReason]); + } + scope.startup(params, BOOTSTRAP_REASONS[this.extension.startupReason]); + ExtensionSupport.loadedBootstrapExtensions.add(this.extension.id); + } + + static onDisable(id) { + if (bootstrapScopes.has(id)) { + bootstrapScopes + .get(id) + .shutdown({ ...cachedParams.get(id) }, BOOTSTRAP_REASONS.ADDON_DISABLE); + ExtensionSupport.loadedBootstrapExtensions.delete(id); + } + } + + static onUpdate(id, manifest) { + if (bootstrapScopes.has(id)) { + let params = { + ...cachedParams.get(id), + newVersion: manifest.version, + }; + let reason = BOOTSTRAP_REASONS.ADDON_UPGRADE; + if (Services.vc.compare(params.newVersion, params.version) < 0) { + reason = BOOTSTRAP_REASONS.ADDON_DOWNGRADE; + } + + let scope = bootstrapScopes.get(id); + scope.shutdown(params, reason); + scope.uninstall(params, reason); + ExtensionSupport.loadedBootstrapExtensions.delete(id); + bootstrapScopes.delete(id); + } + } + + static onUninstall(id) { + if (bootstrapScopes.has(id)) { + bootstrapScopes + .get(id) + .uninstall( + { ...cachedParams.get(id) }, + BOOTSTRAP_REASONS.ADDON_UNINSTALL + ); + bootstrapScopes.delete(id); + } + } + + get extensionFile() { + let uri = Services.io.newURI(this.extension.resourceURL); + if (uri instanceof Ci.nsIJARURI) { + uri = uri.QueryInterface(Ci.nsIJARURI).JARFile; + } + return uri.QueryInterface(Ci.nsIFileURL).file; + } + + loadScope() { + let { extension } = this; + let file = this.extensionFile; + let uri = this.extension.getURL("bootstrap.js"); + let principal = Services.scriptSecurityManager.getSystemPrincipal(); + + let sandbox = new Cu.Sandbox(principal, { + sandboxName: uri, + addonId: this.extension.id, + wantGlobalProperties: ["ChromeUtils"], + metadata: { addonID: this.extension.id, URI: uri }, + }); + + try { + Object.assign(sandbox, BOOTSTRAP_REASONS); + + XPCOMUtils.defineLazyGetter( + sandbox, + "console", + () => new ConsoleAPI({ consoleID: `addon/${this.extension.id}` }) + ); + + Services.scriptloader.loadSubScript(uri, sandbox); + } catch (e) { + logger.warn(`Error loading bootstrap.js for ${this.extension.id}`, e); + } + + function findMethod(name) { + if (sandbox.name) { + return sandbox.name; + } + + try { + let method = Cu.evalInSandbox(name, sandbox); + return method; + } catch (err) {} + + return () => { + logger.warn( + `Add-on ${extension.id} is missing bootstrap method ${name}` + ); + }; + } + + let install = findMethod("install"); + let uninstall = findMethod("uninstall"); + let startup = findMethod("startup"); + let shutdown = findMethod("shutdown"); + + return { + install(...args) { + try { + install(...args); + } catch (ex) { + logger.warn( + `Exception running bootstrap method install on ${extension.id}`, + ex + ); + } + }, + + uninstall(...args) { + try { + uninstall(...args); + } catch (ex) { + logger.warn( + `Exception running bootstrap method uninstall on ${extension.id}`, + ex + ); + } finally { + // Forget any cached files we might've had from this extension. + Services.obs.notifyObservers(null, "startupcache-invalidate"); + } + }, + + startup(...args) { + logger.debug(`Registering manifest for ${file.path}\n`); + Components.manager.addBootstrappedManifestLocation(file); + try { + startup(...args); + } catch (ex) { + logger.warn( + `Exception running bootstrap method startup on ${extension.id}`, + ex + ); + } + }, + + shutdown(data, reason) { + try { + shutdown(data, reason); + } catch (ex) { + logger.warn( + `Exception running bootstrap method shutdown on ${extension.id}`, + ex + ); + } finally { + if (reason != BOOTSTRAP_REASONS.APP_SHUTDOWN) { + logger.debug(`Removing manifest for ${file.path}\n`); + Components.manager.removeBootstrappedManifestLocation(file); + } + } + }, + }; + } +}; + +function getAllWindows() { + function getChildDocShells(parentDocShell) { + let docShells = parentDocShell.getAllDocShellsInSubtree( + Ci.nsIDocShellTreeItem.typeAll, + Ci.nsIDocShell.ENUMERATE_FORWARDS + ); + + for (let docShell of docShells) { + docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + domWindows.push(docShell.domWindow); + } + } + + let domWindows = []; + for (let win of Services.ww.getWindowEnumerator()) { + let parentDocShell = win + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell); + getChildDocShells(parentDocShell); + } + return domWindows; +} diff --git a/browser/components/extensions/schemas/jar.mn b/browser/components/extensions/schemas/jar.mn index 12428cda2619..7077bbdbfd61 100644 --- a/browser/components/extensions/schemas/jar.mn +++ b/browser/components/extensions/schemas/jar.mn @@ -12,6 +12,7 @@ browser.jar: content/browser/schemas/devtools_panels.json content/browser/schemas/find.json content/browser/schemas/history.json + content/browser/schemas/legacy.json content/browser/schemas/menus.json content/browser/schemas/menus_child.json content/browser/schemas/normandyAddonStudy.json diff --git a/browser/components/extensions/schemas/legacy.json b/browser/components/extensions/schemas/legacy.json new file mode 100644 index 000000000000..825e324096be --- /dev/null +++ b/browser/components/extensions/schemas/legacy.json @@ -0,0 +1,43 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "WebExtensionManifest", + "properties": { + "legacy": { + "optional": true, + "choices": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["xul", "bootstrap"], + "optional": true + }, + "options": { + "type": "object", + "properties": { + "page": { + "type": "string" + }, + "open_in_tab": { + "type": "boolean", + "optional": true + } + }, + "optional": true + } + } + } + ] + } + } + } + ] + } +] diff --git a/toolkit/mozapps/extensions/AddonManager.sys.mjs b/toolkit/mozapps/extensions/AddonManager.sys.mjs index e09ea87de86b..2980d7822e98 100644 --- a/toolkit/mozapps/extensions/AddonManager.sys.mjs +++ b/toolkit/mozapps/extensions/AddonManager.sys.mjs @@ -4171,7 +4171,9 @@ export var AddonManager = { AUTOUPDATE_DEFAULT: 1, // Indicates that the Addon should update automatically. AUTOUPDATE_ENABLE: 2, - + // Constants for how Addon options should be shown. + // Options will be opened in a new window + OPTIONS_TYPE_DIALOG: 1, // Constants for how Addon options should be shown. // Options will be displayed in a new tab, if possible OPTIONS_TYPE_TAB: 3, diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs index b4170fce161f..54e92ce08672 100644 --- a/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs +++ b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs @@ -990,6 +990,7 @@ export class AddonWrapper { if (addon.optionsType) { switch (parseInt(addon.optionsType, 10)) { + case lazy.AddonManager.OPTIONS_TYPE_DIALOG: case lazy.AddonManager.OPTIONS_TYPE_TAB: case lazy.AddonManager.OPTIONS_TYPE_INLINE_BROWSER: return hasOptionsURL ? addon.optionsType : null; @@ -2717,8 +2718,8 @@ export const XPIDatabase = { } if (this.isDisabledLegacy(aAddon)) { - logger.warn(`disabling legacy extension ${aAddon.id}`); - return false; + logger.warn(`enabling legacy extension ${aAddon.id}`); + return true; } if (lazy.AddonManager.checkCompatibility) { diff --git a/waterfox/browser/extensions/common/BootstrapLoader.jsm b/waterfox/browser/extensions/common/BootstrapLoader.jsm new file mode 100644 index 000000000000..b1245afe3435 --- /dev/null +++ b/waterfox/browser/extensions/common/BootstrapLoader.jsm @@ -0,0 +1,458 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["BootstrapLoader"]; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonInternal: "resource://gre/modules/addons/XPIDatabase.jsm", + Blocklist: "resource://gre/modules/Blocklist.jsm", + ConsoleAPI: "resource://gre/modules/Console.jsm", + InstallRDF: "resource:///modules/RDFManifestConverter.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +Services.obs.addObserver(doc => { + if ( + doc.location.protocol + doc.location.pathname === "about:addons" || + doc.location.protocol + doc.location.pathname === + "chrome://mozapps/content/extensions/aboutaddons.html" + ) { + const win = doc.defaultView; + let handleEvent_orig = win.customElements.get("addon-card").prototype + .handleEvent; + win.customElements.get("addon-card").prototype.handleEvent = function(e) { + if ( + e.type === "click" && + e.target.getAttribute("action") === "preferences" && + this.addon.optionsType == AddonManager.OPTIONS_TYPE_DIALOG + ) { + let windows = Services.wm.getEnumerator(null); + while (windows.hasMoreElements()) { + let win2 = windows.getNext(); + if (win2.closed) { + continue; + } + if (win2.document.documentURI == this.addon.optionsURL) { + win2.focus(); + return; + } + } + let features = "chrome,titlebar,toolbar,centerscreen"; + let instantApply = Services.prefs.getBoolPref( + "browser.preferences.instantApply" + ); + features += instantApply ? ",dialog=no" : ""; + win.docShell.rootTreeItem.domWindow.openDialog( + this.addon.optionsURL, + this.addon.id, + features + ); + } else { + handleEvent_orig.apply(this, arguments); + } + }; + let update_orig = win.customElements.get("addon-options").prototype.update; + win.customElements.get("addon-options").prototype.update = function( + card, + addon + ) { + update_orig.apply(this, arguments); + if (addon.optionsType == AddonManager.OPTIONS_TYPE_DIALOG) { + this.querySelector('panel-item[action="preferences"]').hidden = false; + } + }; + } +}, "chrome-document-loaded"); + +XPCOMUtils.defineLazyGetter(this, "BOOTSTRAP_REASONS", () => { + const { XPIProvider } = ChromeUtils.import( + "resource://gre/modules/addons/XPIProvider.jsm" + ); + return XPIProvider.BOOTSTRAP_REASONS; +}); + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +var logger = Log.repository.getLogger("addons.bootstrap"); + +/** + * Valid IDs fit this pattern. + */ +var gIDTest = /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i; + +// Properties that exist in the install manifest +const PROP_METADATA = [ + "id", + "version", + "type", + "internalName", + "updateURL", + "optionsURL", + "optionsType", + "aboutURL", + "iconURL", +]; +const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"]; +const PROP_LOCALE_MULTI = ["developers", "translators", "contributors"]; + +// Map new string type identifiers to old style nsIUpdateItem types. +// Retired values: +// 32 = multipackage xpi file +// 8 = locale +// 256 = apiextension +// 128 = experiment +// theme = 4 +const TYPES = { + extension: 2, + dictionary: 64, +}; + +const COMPATIBLE_BY_DEFAULT_TYPES = { + extension: true, + dictionary: true, +}; + +const hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); + +function isXPI(filename) { + let ext = filename.slice(-4).toLowerCase(); + return ext === ".xpi" || ext === ".zip"; +} + +/** + * Creates a jar: URI for a file inside a ZIP file. + * + * @param {nsIFile} aJarfile + * The ZIP file as an nsIFile + * @param {string} aPath + * The path inside the ZIP file + * @returns {nsIURI} + * An nsIURI for the file + */ +function buildJarURI(aJarfile, aPath) { + let uri = Services.io.newFileURI(aJarfile); + uri = "jar:" + uri.spec + "!/" + aPath; + return Services.io.newURI(uri); +} + +/** + * Gets an nsIURI for a file within another file, either a directory or an XPI + * file. If aFile is a directory then this will return a file: URI, if it is an + * XPI file then it will return a jar: URI. + * + * @param {nsIFile} aFile + * The file containing the resources, must be either a directory or an + * XPI file + * @param {string} aPath + * The path to find the resource at, "/" separated. If aPath is empty + * then the uri to the root of the contained files will be returned + * @returns {nsIURI} + * An nsIURI pointing at the resource + */ +function getURIForResourceInFile(aFile, aPath) { + if (!isXPI(aFile.leafName)) { + let resource = aFile.clone(); + if (aPath) { + aPath.split("/").forEach(part => resource.append(part)); + } + + return Services.io.newFileURI(resource); + } + + return buildJarURI(aFile, aPath); +} + +var BootstrapLoader = { + name: "bootstrap", + manifestFile: "install.rdf", + async loadManifest(pkg) { + /** + * Reads locale properties from either the main install manifest root or + * an em:localized section in the install manifest. + * + * @param {Object} aSource + * The resource to read the properties from. + * @param {boolean} isDefault + * True if the locale is to be read from the main install manifest + * root + * @param {string[]} aSeenLocales + * An array of locale names already seen for this install manifest. + * Any locale names seen as a part of this function will be added to + * this array + * @returns {Object} + * an object containing the locale properties + */ + function readLocale(aSource, isDefault, aSeenLocales) { + let locale = {}; + if (!isDefault) { + locale.locales = []; + for (let localeName of aSource.locales || []) { + if (!localeName) { + logger.warn("Ignoring empty locale in localized properties"); + continue; + } + if (aSeenLocales.includes(localeName)) { + logger.warn("Ignoring duplicate locale in localized properties"); + continue; + } + aSeenLocales.push(localeName); + locale.locales.push(localeName); + } + + if (!locale.locales.length) { + logger.warn("Ignoring localized properties with no listed locales"); + return null; + } + } + + for (let prop of [...PROP_LOCALE_SINGLE, ...PROP_LOCALE_MULTI]) { + if (hasOwnProperty(aSource, prop)) { + locale[prop] = aSource[prop]; + } + } + + return locale; + } + + let manifestData = await pkg.readString("install.rdf"); + let manifest = InstallRDF.loadFromString(manifestData).decode(); + + let addon = new AddonInternal(); + for (let prop of PROP_METADATA) { + if (hasOwnProperty(manifest, prop)) { + addon[prop] = manifest[prop]; + } + } + + if (!addon.type) { + addon.type = "extension"; + } else { + let type = addon.type; + addon.type = null; + for (let name in TYPES) { + if (TYPES[name] == type) { + addon.type = name; + break; + } + } + } + + if (!(addon.type in TYPES)) { + throw new Error("Install manifest specifies unknown type: " + addon.type); + } + + if (!addon.id) { + throw new Error("No ID in install manifest"); + } + if (!gIDTest.test(addon.id)) { + throw new Error("Illegal add-on ID " + addon.id); + } + if (!addon.version) { + throw new Error("No version in install manifest"); + } + + addon.strictCompatibility = + !(addon.type in COMPATIBLE_BY_DEFAULT_TYPES) || + manifest.strictCompatibility == "true"; + + // Only read these properties for extensions. + if (addon.type == "extension") { + if (manifest.bootstrap != "true") { + throw new Error("Non-restartless extensions no longer supported"); + } + + if ( + addon.optionsType && + addon.optionsType != AddonManager.OPTIONS_TYPE_DIALOG && + addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE_BROWSER && + addon.optionsType != AddonManager.OPTIONS_TYPE_TAB + ) { + throw new Error( + "Install manifest specifies unknown optionsType: " + addon.optionsType + ); + } + } else { + // Convert legacy dictionaries into a format the WebExtension + // dictionary loader can process. + if (addon.type === "dictionary") { + addon.loader = null; + let dictionaries = {}; + await pkg.iterFiles(({ path }) => { + let match = /^dictionaries\/([^\/]+)\.dic$/.exec(path); + if (match) { + let lang = match[1].replace(/_/g, "-"); + dictionaries[lang] = match[0]; + } + }); + addon.startupData = { dictionaries }; + } + + // Only extensions are allowed to provide an optionsURL, optionsType, + // optionsBrowserStyle, or aboutURL. For all other types they are silently ignored + addon.aboutURL = null; + addon.optionsBrowserStyle = null; + addon.optionsType = null; + addon.optionsURL = null; + } + + addon.defaultLocale = readLocale(manifest, true); + + let seenLocales = []; + addon.locales = []; + for (let localeData of manifest.localized || []) { + let locale = readLocale(localeData, false, seenLocales); + if (locale) { + addon.locales.push(locale); + } + } + + let dependencies = new Set(manifest.dependencies); + addon.dependencies = Object.freeze(Array.from(dependencies)); + + let seenApplications = []; + addon.targetApplications = []; + for (let targetApp of manifest.targetApplications || []) { + if (!targetApp.id || !targetApp.minVersion || !targetApp.maxVersion) { + logger.warn( + "Ignoring invalid targetApplication entry in install manifest" + ); + continue; + } + if (seenApplications.includes(targetApp.id)) { + logger.warn( + "Ignoring duplicate targetApplication entry for " + + targetApp.id + + " in install manifest" + ); + continue; + } + seenApplications.push(targetApp.id); + addon.targetApplications.push(targetApp); + } + + // Note that we don't need to check for duplicate targetPlatform entries since + // the RDF service coalesces them for us. + addon.targetPlatforms = []; + for (let targetPlatform of manifest.targetPlatforms || []) { + let platform = { + os: null, + abi: null, + }; + + let pos = targetPlatform.indexOf("_"); + if (pos != -1) { + platform.os = targetPlatform.substring(0, pos); + platform.abi = targetPlatform.substring(pos + 1); + } else { + platform.os = targetPlatform; + } + + addon.targetPlatforms.push(platform); + } + + addon.userDisabled = false; + addon.softDisabled = addon.blocklistState == Blocklist.STATE_SOFTBLOCKED; + addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT; + + addon.userPermissions = null; + + addon.icons = {}; + if (await pkg.hasResource("icon.png")) { + addon.icons[32] = "icon.png"; + addon.icons[48] = "icon.png"; + } + + if (await pkg.hasResource("icon64.png")) { + addon.icons[64] = "icon64.png"; + } + + return addon; + }, + + loadScope(addon) { + let file = addon.file || addon._sourceBundle; + let uri = getURIForResourceInFile(file, "bootstrap.js").spec; + let principal = Services.scriptSecurityManager.getSystemPrincipal(); + + let sandbox = new Cu.Sandbox(principal, { + sandboxName: uri, + addonId: addon.id, + wantGlobalProperties: ["ChromeUtils"], + metadata: { addonID: addon.id, URI: uri }, + }); + + try { + Object.assign(sandbox, BOOTSTRAP_REASONS); + + XPCOMUtils.defineLazyGetter( + sandbox, + "console", + () => new ConsoleAPI({ consoleID: `addon/${addon.id}` }) + ); + + Services.scriptloader.loadSubScript(uri, sandbox); + } catch (e) { + logger.warn(`Error loading bootstrap.js for ${addon.id}`, e); + } + + function findMethod(name) { + if (sandbox.name) { + return sandbox.name; + } + + try { + let method = Cu.evalInSandbox(name, sandbox); + return method; + } catch (err) {} + + return () => { + logger.warn(`Add-on ${addon.id} is missing bootstrap method ${name}`); + }; + } + + let install = findMethod("install"); + let uninstall = findMethod("uninstall"); + let startup = findMethod("startup"); + let shutdown = findMethod("shutdown"); + + return { + install: (...args) => install(...args), + + uninstall(...args) { + uninstall(...args); + // Forget any cached files we might've had from this extension. + Services.obs.notifyObservers(null, "startupcache-invalidate"); + }, + + startup(...args) { + if (addon.type == "extension") { + logger.debug(`Registering manifest for ${file.path}\n`); + Components.manager.addBootstrappedManifestLocation(file); + } + return startup(...args); + }, + + shutdown(data, reason) { + try { + return shutdown(data, reason); + } catch (err) { + throw err; + } finally { + if (reason != BOOTSTRAP_REASONS.APP_SHUTDOWN) { + logger.debug(`Removing manifest for ${file.path}\n`); + Components.manager.removeBootstrappedManifestLocation(file); + } + } + }, + }; + }, +}; diff --git a/waterfox/browser/extensions/common/ChromeManifest.jsm b/waterfox/browser/extensions/common/ChromeManifest.jsm new file mode 100644 index 000000000000..bbf5f8f19bb0 --- /dev/null +++ b/waterfox/browser/extensions/common/ChromeManifest.jsm @@ -0,0 +1,363 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["ChromeManifest"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +/** + * A parser for chrome.manifest files. Implements a subset of + * https://developer.mozilla.org/en-US/docs/Mozilla/Chrome_Registration + */ +class ChromeManifest { + /** + * Constructs the chrome.manifest parser + * + * @param {Function} loader An asynchronous function that will load further files, e.g. + * those included via the |manifest| instruction. The + * function will take the file as an argument and should + * resolve with the string contents of that file + * @param {Object} options Object describing the current system. The keys are manifest + * instructions + */ + constructor(loader, options) { + this.loader = loader; + this.options = options; + + this.overlay = new DefaultMap(() => []); + this.locales = new DefaultMap(() => new Map()); + this.style = new DefaultMap(() => new Set()); + this.category = new DefaultMap(() => new Map()); + + this.component = new Map(); + this.contract = new Map(); + + this.content = new Map(); + this.skin = new Map(); + this.resource = new Map(); + this.override = new Map(); + } + + /** + * Parse the given file. + * + * @param {string} filename The filename to load + * @param {string} base The relative directory this file is expected to be in. + * @returns {Promise} Resolved when loading completes + */ + async parse(filename = "chrome.manifest", base = "") { + await this.parseString(await this.loader(filename), base); + } + + /** + * Parse the given string. + * + * @param {string} data The file data to load + * @param {string} base The relative directory this file is expected to be in. + * @returns {Promise} Resolved when loading completes + */ + async parseString(data, base = "") { + let lines = data.split("\n"); + let extraManifests = []; + for (let line of lines) { + let parts = line.trim().split(/\s+/); + let directive = parts.shift(); + switch (directive) { + case "manifest": + extraManifests.push(this._parseManifest(base, ...parts)); + break; + case "component": + this._parseComponent(...parts); + break; + case "contract": + this._parseContract(...parts); + break; + + case "category": + this._parseCategory(...parts); + break; + case "content": + this._parseContent(...parts); + break; + case "locale": + this._parseLocale(...parts); + break; + case "skin": + this._parseSkin(...parts); + break; + case "resource": + this._parseResource(...parts); + break; + + case "overlay": + this._parseOverlay(...parts); + break; + case "style": + this._parseStyle(...parts); + break; + case "override": + this._parseOverride(...parts); + break; + } + } + + await Promise.all(extraManifests); + } + + /** + * Ensure the flags provided for the instruction match our options + * + * @param {string[]} flags An array of raw flag values in the form key=value. + * @returns {boolean} True, if the flags match the options provided in the constructor + */ + _parseFlags(flags) { + if (!flags.length) { + return true; + } + + let matchString = (a, sign, b) => { + if (sign != "=") { + console.warn( + `Invalid sign ${sign} in ${a}${sign}${b}, dropping manifest instruction` + ); + return false; + } + return a == b; + }; + + let matchVersion = (a, sign, b) => { + switch (sign) { + case "=": + return Services.vc.compare(a, b) == 0; + case ">": + return Services.vc.compare(a, b) > 0; + case "<": + return Services.vc.compare(a, b) < 0; + case ">=": + return Services.vc.compare(a, b) >= 0; + case "<=": + return Services.vc.compare(a, b) <= 0; + default: + console.warn( + `Invalid sign ${sign} in ${a}${sign}${b}, dropping manifest instruction` + ); + return false; + } + }; + + let flagMatches = (key, typeMatch) => { + return ( + !flagdata.has(key) || + flagdata.get(key).some(val => typeMatch(this.options[key], ...val)) + ); + }; + + let flagdata = new DefaultMap(() => []); + + for (let flag of flags) { + let match = flag.match(/(\w+)(>=|<=|<|>|=)(.*)/); + if (match) { + flagdata.get(match[1]).push([match[2], match[3]]); + } else { + console.warn(`Invalid flag ${flag}, dropping manifest instruction`); + } + } + + return ( + flagMatches("application", matchString) && + flagMatches("appversion", matchVersion) && + flagMatches("platformversion", matchVersion) && + flagMatches("os", matchString) && + flagMatches("osversion", matchVersion) && + flagMatches("abi", matchString) + ); + } + + /** + * Parse the manifest instruction, to load other files + * + * @param {string} base The base directory the manifest file is in + * @param {string} filename The file and path to load + * @param {...string} flags The flags for this instruction + * @returns {Promise} Promise resolved when the manifest is loaded + */ + async _parseManifest(base, filename, ...flags) { + if (this._parseFlags(flags)) { + let dirparts = filename.split("/"); + dirparts.pop(); + + try { + await this.parse(filename, base + "/" + dirparts.join("/")); + } catch (e) { + console.log(`Could not read manifest '${base}/${filename}'.`); + } + } + return null; + } + + /** + * Parse the component instruction, to load xpcom components + * + * @param {string} classid The xpcom class id to load + * @param {string} loction The file location of this component + * @param {...string} flags The flags for this instruction + */ + _parseComponent(classid, location, ...flags) { + if (this._parseFlags(flags)) { + this.component.set(classid, location); + } + } + + /** + * Parse the contract instruction, to load xpcom contract ids + * + * @param {string} contractid The xpcom contract id to load + * @param {string} location The file location of this component + * @param {...string} flags The flags for this instruction + */ + _parseContract(contractid, location, ...flags) { + if (this._parseFlags(flags)) { + this.contract.set(contractid, location); + } + } + + /** + * Parse the category instruction, to set up xpcom categories + * + * @param {string} category The name of the category + * @param {string} entryName The category entry name + * @param {string} value The category entry value + * @param {...string} flags The flags for this instruction + */ + _parseCategory(category, entryName, value, ...flags) { + if (this._parseFlags(flags)) { + this.category.get(category).set(entryName, value); + } + } + + /** + * Parse the content instruction, to set chrome content locations + * + * @param {string} shortname The content short name, e.g. chrome://shortname/content/ + * @param {string} location The location for this content registration + * @param {...string} flags The flags for this instruction + */ + _parseContent(shortname, location, ...flags) { + if (this._parseFlags(flags)) { + this.content.set(shortname, location); + } + } + + /** + * Parse the locale instruction, to set chrome locale locations + * + * @param {string} shortname The locale short name, e.g. chrome://shortname/locale/ + * @param {string} location The location for this locale registration + * @param {...string} flags The flags for this instruction + */ + _parseLocale(shortname, locale, location, ...flags) { + if (this._parseFlags(flags)) { + this.locales.get(shortname).set(locale, location); + } + } + + /** + * Parse the skin instruction, to set chrome skin locations + * + * @param {string} shortname The skin short name, e.g. chrome://shortname/skin/ + * @param {string} location The location for this skin registration + * @param {...string} flags The flags for this instruction + */ + _parseSkin(packagename, skinname, location, ...flags) { + if (this._parseFlags(flags)) { + this.skin.set(packagename, location); + } + } + + /** + * Parse the resource instruction, to set up resource uri substitutions + * + * @param {string} packagename The resource package name, e.g. resource://packagename/ + * @param {string} url The location for this content registration + * @param {...string} flags The flags for this instruction + */ + _parseResource(packagename, location, ...flags) { + if (this._parseFlags(flags)) { + this.resource.set(packagename, location); + } + } + + /** + * Parse the overlay instruction, to set up xul overlays + * + * @param {string} targetUrl The chrome target url + * @param {string} overlayUrl The url of the xul overlay + * @param {...string} flags The flags for this instruction + */ + _parseOverlay(targetUrl, overlayUrl, ...flags) { + if (this._parseFlags(flags)) { + this.overlay.get(targetUrl).push(overlayUrl); + } + } + + /** + * Parse the style instruction, to add stylesheets into chrome windows + * + * @param {string} uri The uri of the chrome window + * @param {string} sheet The uri of the css sheet + * @param {...string} flags The flags for this instruction + */ + _parseStyle(uri, sheet, ...flags) { + if (this._parseFlags(flags)) { + this.style.get(uri).add(sheet); + } + } + + /** + * Parse the override instruction, to set chrome uri overrides + * + * @param {string} uri The uri being overridden + * @param {string} newuri The replacement uri for the original location + * @param {...string} flags The flags for this instruction + */ + _parseOverride(uri, newuri, ...flags) { + if (this._parseFlags(flags)) { + this.override.set(uri, newuri); + } + } +} + +/** + * A default map, which assumes a default value on get() if the key doesn't exist + */ +class DefaultMap extends Map { + /** + * Constructs the default map + * + * @param {Function} _default A function that returns the default value for this map + * @param {*} iterable An iterable to initialize the map with + */ + constructor(_default, iterable) { + super(iterable); + this._default = _default; + } + + /** + * Get the given key, creating if necessary + * + * @param {string} key The key of the map to get + * @param {boolean} create True, if the key should be created in case it doesn't exist. + */ + get(key, create = true) { + if (this.has(key)) { + return super.get(key); + } else if (create) { + this.set(key, this._default()); + return super.get(key); + } + + return this._default(); + } +} diff --git a/waterfox/browser/extensions/common/ExtensionSupport.jsm b/waterfox/browser/extensions/common/ExtensionSupport.jsm new file mode 100644 index 000000000000..e7441cef1a4e --- /dev/null +++ b/waterfox/browser/extensions/common/ExtensionSupport.jsm @@ -0,0 +1,410 @@ +/* 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"; + +/** + * Helper functions for use by extensions that should ease them plug + * into the application. + */ + +this.EXPORTED_SYMBOLS = ["ExtensionSupport"]; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +// ChromeUtils.import("resource://gre/modules/Deprecated.jsm") - needed for warning. +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + +var { fixIterator } = ChromeUtils.import( + "resource:///modules/iteratorUtils.jsm" +); +const { IOUtils } = ChromeUtils.import("resource:///modules/IOUtils.jsm"); + +var extensionHooks = new Map(); +var legacyExtensions = new Map(); +var openWindowList; + +var ExtensionSupport = { + /** + * A Map-like object which tracks legacy extension status. The "has" method + * returns only active extensions for compatibility with existing code. + */ + loadedLegacyExtensions: { + set(id, state) { + legacyExtensions.set(id, state); + }, + get(id) { + return legacyExtensions.get(id); + }, + has(id) { + if (!legacyExtensions.has(id)) { + return false; + } + + let state = legacyExtensions.get(id); + return !["install", "enable"].includes(state.pendingOperation); + }, + hasAnyState(id) { + return legacyExtensions.has(id); + }, + _maybeDelete(id, newPendingOperation) { + if (!legacyExtensions.has(id)) { + return; + } + + let state = legacyExtensions.get(id); + if ( + state.pendingOperation == "enable" && + newPendingOperation == "disable" + ) { + legacyExtensions.delete(id); + this.notifyObservers(state); + } else if ( + state.pendingOperation == "install" && + newPendingOperation == "uninstall" + ) { + legacyExtensions.delete(id); + this.notifyObservers(state); + } + }, + notifyObservers(state) { + let wrappedState = { wrappedJSObject: state }; + Services.obs.notifyObservers(wrappedState, "legacy-addon-status-changed"); + }, + // AddonListener + onDisabled(ev) { + this._maybeDelete(ev.id, "disable"); + }, + onUninstalled(ev) { + this._maybeDelete(ev.id, "uninstall"); + }, + }, + + loadAddonPrefs(addonFile) { + function setPref(preferDefault, name, value) { + let branch = preferDefault + ? Services.prefs.getDefaultBranch("") + : Services.prefs.getBranch(""); + + if (typeof value == "boolean") { + branch.setBoolPref(name, value); + } else if (typeof value == "string") { + if (value.startsWith("chrome://") && value.endsWith(".properties")) { + let valueLocal = Cc[ + "@mozilla.org/pref-localizedstring;1" + ].createInstance(Ci.nsIPrefLocalizedString); + valueLocal.data = value; + branch.setComplexValue(name, Ci.nsIPrefLocalizedString, valueLocal); + } else { + branch.setStringPref(name, value); + } + } else if (typeof value == "number" && Number.isInteger(value)) { + branch.setIntPref(name, value); + } else if (typeof value == "number" && Number.isFloat(value)) { + // Floats are set as char prefs, then retrieved using getFloatPref + branch.setCharPref(name, value); + } + } + + function walkExtensionPrefs(extensionRoot) { + let prefFile = extensionRoot.clone(); + let foundPrefStrings = []; + if (!prefFile.exists()) { + return []; + } + + if (prefFile.isDirectory()) { + prefFile.append("defaults"); + prefFile.append("preferences"); + if (!prefFile.exists() || !prefFile.isDirectory()) { + return []; + } + + let unsortedFiles = []; + for (let file of fixIterator(prefFile.directoryEntries, Ci.nsIFile)) { + if (file.isFile() && file.leafName.toLowerCase().endsWith(".js")) { + unsortedFiles.push(file); + } + } + + for (let file of unsortedFiles.sort((a, b) => + a.path < b.path ? 1 : -1 + )) { + foundPrefStrings.push(IOUtils.loadFileToString(file)); + } + } else if (prefFile.isFile() && prefFile.leafName.endsWith("xpi")) { + let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( + Ci.nsIZipReader + ); + zipReader.open(prefFile); + let entries = zipReader.findEntries("defaults/preferences/*.js"); + let unsortedEntries = []; + while (entries.hasMore()) { + unsortedEntries.push(entries.getNext()); + } + + for (let entryName of unsortedEntries.sort().reverse()) { + let stream = zipReader.getInputStream(entryName); + let entrySize = zipReader.getEntry(entryName).realSize; + if (entrySize > 0) { + let content = NetUtil.readInputStreamToString(stream, entrySize, { + charset: "utf-8", + replacement: "?", + }); + foundPrefStrings.push(content); + } + } + } + + return foundPrefStrings; + } + + let sandbox = new Cu.Sandbox(null); + sandbox.pref = setPref.bind(undefined, true); + sandbox.user_pref = setPref.bind(undefined, false); + + let prefDataStrings = walkExtensionPrefs(addonFile); + for (let prefDataString of prefDataStrings) { + try { + Cu.evalInSandbox(prefDataString, sandbox); + } catch (e) { + Cu.reportError( + "Error reading default prefs of addon " + + addonFile.leafName + + ": " + + e + ); + } + } + + /* + TODO: decide whether we need to warn the user/make addon authors to migrate away from these pref files. + if (prefDataStrings.length > 0) { + Deprecated.warning(addon.defaultLocale.name + " uses defaults/preferences/*.js files to load prefs", + "https://bugzilla.mozilla.org/show_bug.cgi?id=1414398"); + } + */ + }, + + /** + * Register listening for windows getting opened that will run the specified callback function + * when a matching window is loaded. + * + * @param aID {String} Some identification of the caller, usually the extension ID. + * @param aExtensionHook {Object} The object describing the hook the caller wants to register. + * Members of the object can be (all optional, but one callback must be supplied): + * chromeURLs {Array} An array of strings of document URLs on which + * the given callback should run. If not specified, + * run on all windows. + * onLoadWindow {function} The callback function to run when window loads + * the matching document. + * onUnloadWindow {function} The callback function to run when window + * unloads the matching document. + * Both callbacks receive the matching window object as argument. + * + * @returns {boolean} True if the passed arguments were valid and the caller could be registered. + * False otherwise. + */ + registerWindowListener(aID, aExtensionHook) { + if (!aID) { + Cu.reportError("No extension ID provided for the window listener"); + return false; + } + + if (extensionHooks.has(aID)) { + Cu.reportError( + "Window listener for extension + '" + aID + "' already registered" + ); + return false; + } + + if ( + !("onLoadWindow" in aExtensionHook) && + !("onUnloadWindow" in aExtensionHook) + ) { + Cu.reportError( + "The extension + '" + aID + "' does not provide any callbacks" + ); + return false; + } + + extensionHooks.set(aID, aExtensionHook); + + // Add our global listener if there isn't one already + // (only when we have first caller). + if (extensionHooks.size == 1) { + Services.wm.addListener(this._windowListener); + } + + if (openWindowList) { + // We already have a list of open windows, notify the caller about them. + openWindowList.forEach(domWindow => + ExtensionSupport._checkAndRunMatchingExtensions(domWindow, "load", aID) + ); + } else { + openWindowList = new Set(); + // Get the list of windows already open. + let windows = Services.wm.getEnumerator(null); + while (windows.hasMoreElements()) { + let domWindow = windows.getNext().QueryInterface(Ci.nsIDOMWindow); + if (domWindow.document.location.href === "about:blank") { + ExtensionSupport._waitForLoad(domWindow, aID); + } else { + ExtensionSupport._addToListAndNotify(domWindow, aID); + } + } + } + + return true; + }, + + /** + * Unregister listening for windows for the given caller. + * + * @param aID {String} Some identification of the caller, usually the extension ID. + * + * @returns {boolean} True if the passed arguments were valid and the caller could be unregistered. + * False otherwise. + */ + unregisterWindowListener(aID) { + if (!aID) { + Cu.reportError("No extension ID provided for the window listener"); + return false; + } + + let windowListener = extensionHooks.get(aID); + if (!windowListener) { + Cu.reportError( + "Couldn't remove window listener for extension + '" + aID + "'" + ); + return false; + } + + extensionHooks.delete(aID); + // Remove our global listener if there are no callers registered anymore. + if (extensionHooks.size == 0) { + Services.wm.removeListener(this._windowListener); + openWindowList.clear(); + openWindowList = undefined; + } + + return true; + }, + + get openWindows() { + return openWindowList.values(); + }, + + _windowListener: { + // nsIWindowMediatorListener functions + onOpenWindow(xulWindow) { + // A new window has opened. + let domWindow = xulWindow.docShell.domWindow; + + // Here we pass no caller ID, so all registered callers get notified. + ExtensionSupport._waitForLoad(domWindow); + }, + + onCloseWindow(xulWindow) { + // One of the windows has closed. + let domWindow = xulWindow.docShell.domWindow; + openWindowList.delete(domWindow); + }, + }, + + /** + * Set up listeners to run the callbacks on the given window. + * + * @param aWindow {nsIDOMWindow} The window to set up. + * @param aID {String} Optional. ID of the new caller that has registered right now. + */ + _waitForLoad(aWindow, aID) { + // Wait for the load event of the window. At that point + // aWindow.document.location.href will not be "about:blank" any more. + aWindow.addEventListener( + "load", + function() { + ExtensionSupport._addToListAndNotify(aWindow, aID); + }, + { once: true } + ); + }, + + /** + * Once the window is fully loaded with the href referring to the XUL document, + * add it to our list, attach the "unload" listener to it and notify interested + * callers. + * + * @param aWindow {nsIDOMWindow} The window to process. + * @param aID {String} Optional. ID of the new caller that has registered right now. + */ + _addToListAndNotify(aWindow, aID) { + openWindowList.add(aWindow); + aWindow.addEventListener( + "unload", + function() { + ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "unload"); + }, + { once: true } + ); + ExtensionSupport._checkAndRunMatchingExtensions(aWindow, "load", aID); + }, + + /** + * Check if the caller matches the given window and run its callback function. + * + * @param aWindow {nsIDOMWindow} The window to run the callbacks on. + * @param aEventType {String} Which callback to run if caller matches (load/unload). + * @param aID {String} Optional ID of the caller whose callback is to be run. + * If not given, all registered callers are notified. + */ + _checkAndRunMatchingExtensions(aWindow, aEventType, aID) { + if (aID) { + checkAndRunExtensionCode(extensionHooks.get(aID)); + } else { + for (let extensionHook of extensionHooks.values()) { + checkAndRunExtensionCode(extensionHook); + } + } + + /** + * Check if the single given caller matches the given window + * and run its callback function. + * + * @param aExtensionHook {Object} The object describing the hook the caller + * has registered. + */ + function checkAndRunExtensionCode(aExtensionHook) { + let windowChromeURL = aWindow.document.location.href; + // Check if extension applies to this document URL. + if ( + "chromeURLs" in aExtensionHook && + !aExtensionHook.chromeURLs.some(url => url == windowChromeURL) + ) { + return; + } + + // Run the relevant callback. + switch (aEventType) { + case "load": + if ("onLoadWindow" in aExtensionHook) { + aExtensionHook.onLoadWindow(aWindow); + } + break; + case "unload": + if ("onUnloadWindow" in aExtensionHook) { + aExtensionHook.onUnloadWindow(aWindow); + } + break; + } + } + }, + + get registeredWindowListenerCount() { + return extensionHooks.size; + }, +}; + +AddonManager.addAddonListener(ExtensionSupport.loadedLegacyExtensions); diff --git a/waterfox/browser/extensions/common/IOUtils.jsm b/waterfox/browser/extensions/common/IOUtils.jsm new file mode 100644 index 000000000000..068ccfa0ee90 --- /dev/null +++ b/waterfox/browser/extensions/common/IOUtils.jsm @@ -0,0 +1,143 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["IOUtils"]; + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var kStringBlockSize = 4096; +var kStreamBlockSize = 8192; + +var IOUtils = { + /** + * Read a file containing ASCII text into a string. + * + * @param aFile An nsIFile representing the file to read or a string containing + * the file name of a file under user's profile. + * @returns A string containing the contents of the file, presumed to be ASCII + * text. If the file didn't exist, returns null. + */ + loadFileToString(aFile) { + let file; + if (!(aFile instanceof Ci.nsIFile)) { + file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append(aFile); + } else { + file = aFile; + } + + if (!file.exists()) { + return null; + } + + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + // PR_RDONLY + fstream.init(file, 0x01, 0, 0); + + let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sstream.init(fstream); + + let data = ""; + while (sstream.available()) { + data += sstream.read(kStringBlockSize); + } + + sstream.close(); + fstream.close(); + + return data; + }, + + /** + * Save a string containing ASCII text into a file. The file will be overwritten + * and contain only the given text. + * + * @param aFile An nsIFile representing the file to write or a string containing + * the file name of a file under user's profile. + * @param aData The string to write. + * @param aPerms The octal file permissions for the created file. If unset + * the default of 0o600 is used. + */ + saveStringToFile(aFile, aData, aPerms = 0o600) { + let file; + if (!(aFile instanceof Ci.nsIFile)) { + file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append(aFile); + } else { + file = aFile; + } + + let foStream = Cc[ + "@mozilla.org/network/safe-file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + + // PR_WRONLY + PR_CREATE_FILE + PR_TRUNCATE + foStream.init(file, 0x02 | 0x08 | 0x20, aPerms, 0); + // safe-file-output-stream appears to throw an error if it doesn't write everything at once + // so we won't worry about looping to deal with partial writes. + // In case we try to use this function for big files where buffering + // is needed we could use the implementation in saveStreamToFile(). + foStream.write(aData, aData.length); + foStream.QueryInterface(Ci.nsISafeOutputStream).finish(); + foStream.close(); + }, + + /** + * Saves the given input stream to a file. + * + * @param aIStream The input stream to save. + * @param aFile The file to which the stream is saved. + * @param aPerms The octal file permissions for the created file. If unset + * the default of 0o600 is used. + */ + saveStreamToFile(aIStream, aFile, aPerms = 0o600) { + if (!(aIStream instanceof Ci.nsIInputStream)) { + throw new Error("Invalid stream passed to saveStreamToFile"); + } + if (!(aFile instanceof Ci.nsIFile)) { + throw new Error("Invalid file passed to saveStreamToFile"); + } + + let fstream = Cc[ + "@mozilla.org/network/safe-file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + let buffer = Cc[ + "@mozilla.org/network/buffered-output-stream;1" + ].createInstance(Ci.nsIBufferedOutputStream); + + // Write the input stream to the file. + // PR_WRITE + PR_CREATE + PR_TRUNCATE + fstream.init(aFile, 0x04 | 0x08 | 0x20, aPerms, 0); + buffer.init(fstream, kStreamBlockSize); + + buffer.writeFrom(aIStream, aIStream.available()); + + // Close the output streams. + if (buffer instanceof Ci.nsISafeOutputStream) { + buffer.finish(); + } else { + buffer.close(); + } + if (fstream instanceof Ci.nsISafeOutputStream) { + fstream.finish(); + } else { + fstream.close(); + } + + // Close the input stream. + aIStream.close(); + return aFile; + }, + + /** + * Returns size of system memory. + */ + getPhysicalMemorySize() { + return Services.sysinfo.getPropertyAsInt64("memsize"); + }, +}; diff --git a/waterfox/browser/extensions/common/Overlays.jsm b/waterfox/browser/extensions/common/Overlays.jsm new file mode 100644 index 000000000000..0ff6b7ebb0d2 --- /dev/null +++ b/waterfox/browser/extensions/common/Overlays.jsm @@ -0,0 +1,599 @@ +/* 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/. */ + +/** + * Load overlays in a similar way as XUL did for legacy XUL add-ons. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Overlays"]; + +const { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "setTimeout", + "resource://gre/modules/Timer.jsm" +); + +let oconsole = new ConsoleAPI({ + prefix: "Overlays.jsm", + consoleID: "overlays-jsm", + maxLogLevelPref: "extensions.overlayloader.loglevel", +}); + +/** + * The overlays class, providing support for loading overlays like they used to work. This class + * should likely be called through its static method Overlays.load() + */ +class Overlays { + /** + * Load overlays for the given window using the overlay provider, which can for example be a + * ChromeManifest object. + * + * @param {ChromeManifest} overlayProvider The overlay provider that contains information + * about styles and overlays. + * @param {DOMWindow} window The window to load into + */ + static load(overlayProvider, window) { + let instance = new Overlays(overlayProvider, window); + + let urls = overlayProvider.overlay.get(instance.location, false); + instance.load(urls); + } + + /** + * Constructs the overlays instance. This class should be called via Overlays.load() instead. + * + * @param {ChromeManifest} overlayProvider The overlay provider that contains information + * about styles and overlays. + * @param {DOMWindow} window The window to load into + */ + constructor(overlayProvider, window) { + this.overlayProvider = overlayProvider; + this.window = window; + if (window.location.protocol == "about:") { + this.location = window.location.protocol + window.location.pathname; + } else { + this.location = window.location.origin + window.location.pathname; + } + } + + /** + * A shorthand to this.window.document + */ + get document() { + return this.window.document; + } + + /** + * Loads the given urls into the window, recursively loading further overlays as provided by the + * overlayProvider. + * + * @param {string[]} urls The urls to load + */ + load(urls) { + let unloadedOverlays = this._collectOverlays(this.document).concat(urls); + let forwardReferences = []; + let unloadedScripts = []; + let unloadedSheets = []; + this._toolbarsToResolve = []; + let xulStore = Services.xulStore; + this.persistedIDs = new Set(); + + // Load css styles from the registry + for (let sheet of this.overlayProvider.style.get(this.location, false)) { + unloadedSheets.push(sheet); + } + + if (!unloadedOverlays.length && !unloadedSheets.length) { + return; + } + + while (unloadedOverlays.length) { + let url = unloadedOverlays.shift(); + let xhr = this.fetchOverlay(url); + let doc = xhr.responseXML; + + oconsole.debug(`Applying ${url} to ${this.location}`); + + // clean the document a bit + let emptyNodes = doc.evaluate( + "//text()[normalize-space(.) = '']", + doc, + null, + 7, + null + ); + for (let i = 0, len = emptyNodes.snapshotLength; i < len; ++i) { + let node = emptyNodes.snapshotItem(i); + node.remove(); + } + + let commentNodes = doc.evaluate("//comment()", doc, null, 7, null); + for (let i = 0, len = commentNodes.snapshotLength; i < len; ++i) { + let node = commentNodes.snapshotItem(i); + node.remove(); + } + + // Load css styles from the registry + for (let sheet of this.overlayProvider.style.get(url, false)) { + unloadedSheets.push(sheet); + } + + // Load css processing instructions from the overlay + let stylesheets = doc.evaluate( + "/processing-instruction('xml-stylesheet')", + doc, + null, + 7, + null + ); + for (let i = 0, len = stylesheets.snapshotLength; i < len; ++i) { + let node = stylesheets.snapshotItem(i); + let match = node.nodeValue.match(/href=["']([^"']*)["']/); + if (match) { + unloadedSheets.push(new URL(match[1], node.baseURI).href); + } + } + + // Prepare loading further nested xul overlays from the overlay + unloadedOverlays.push(...this._collectOverlays(doc)); + + // Prepare loading further nested xul overlays from the registry + for (let overlayUrl of this.overlayProvider.overlay.get(url, false)) { + unloadedOverlays.push(overlayUrl); + } + + // Run through all overlay nodes on the first level (hookup nodes). Scripts will be deferred + // until later for simplicity (c++ code seems to process them earlier?). + for (let node of doc.documentElement.children) { + if (node.localName == "script") { + unloadedScripts.push(node); + } else { + forwardReferences.push(node); + } + } + } + + let ids = xulStore.getIDsEnumerator(this.location); + while (ids.hasMore()) { + this.persistedIDs.add(ids.getNext()); + } + + // At this point, all (recursive) overlays are loaded. Unloaded scripts and sheets are ready and + // in order, and forward references are good to process. + let previous = 0; + while (forwardReferences.length && forwardReferences.length != previous) { + previous = forwardReferences.length; + let unresolved = []; + + for (let ref of forwardReferences) { + if (!this._resolveForwardReference(ref)) { + unresolved.push(ref); + } + } + + forwardReferences = unresolved; + } + + if (forwardReferences.length) { + oconsole.warn( + `Could not resolve ${forwardReferences.length} references`, + forwardReferences + ); + } + + // Loading the sheets now to avoid race conditions with xbl bindings + for (let sheet of unloadedSheets) { + this.loadCSS(sheet); + } + + this._decksToResolve = new Map(); + for (let id of this.persistedIDs.values()) { + let element = this.document.getElementById(id); + if (element) { + let attrNames = xulStore.getAttributeEnumerator(this.location, id); + while (attrNames.hasMore()) { + let attrName = attrNames.getNext(); + let attrValue = xulStore.getValue(this.location, id, attrName); + if (attrName == "selectedIndex" && element.localName == "deck") { + this._decksToResolve.set(element, attrValue); + } else { + element.setAttribute(attrName, attrValue); + } + } + } + } + + // We've resolved all the forward references we can, we can now go ahead and load the scripts + let deferredLoad = []; + for (let script of unloadedScripts) { + deferredLoad.push(...this.loadScript(script)); + } + + if (this.document.readyState == "complete") { + let sheet; + let overlayTrigger = this.document.createXULElement("overlayTrigger"); + overlayTrigger.addEventListener( + "bindingattached", + () => { + oconsole.debug("XBL binding attached, continuing with load"); + if (sheet) { + sheet.remove(); + } + overlayTrigger.remove(); + + setTimeout(() => { + this._finish(); + + // Now execute load handlers since we are done loading scripts + let bubbles = []; + for (let { listener, useCapture } of deferredLoad) { + if (useCapture) { + this._fireEventListener(listener); + } else { + bubbles.push(listener); + } + } + + for (let listener of bubbles) { + this._fireEventListener(listener); + } + }, 0); + }, + { once: true } + ); + this.document.documentElement.appendChild(overlayTrigger); + if (overlayTrigger.parentNode) { + sheet = this.loadCSS("chrome://messenger/content/overlayBindings.css"); + } + } else { + this.document.defaultView.addEventListener( + "load", + this._finish.bind(this), + { once: true } + ); + } + } + + _finish() { + for (let [deck, selectedIndex] of this._decksToResolve.entries()) { + deck.setAttribute("selectedIndex", selectedIndex); + } + + for (let bar of this._toolbarsToResolve) { + let currentset = Services.xulStore.getValue( + this.location, + bar.id, + "currentset" + ); + if (currentset) { + bar.currentSet = currentset; + } else if (bar.getAttribute("defaultset")) { + bar.currentSet = bar.getAttribute("defaultset"); + } + } + } + + /** + * Gets the overlays referenced by processing instruction on a document. + * + * @param {DOMDocument} document The document to read instuctions from + * @returns {string[]} URLs of the overlays from the document + */ + _collectOverlays(doc) { + let urls = []; + let instructions = doc.evaluate( + "/processing-instruction('xul-overlay')", + doc, + null, + 7, + null + ); + for (let i = 0, len = instructions.snapshotLength; i < len; ++i) { + let node = instructions.snapshotItem(i); + let match = node.nodeValue.match(/href=["']([^"']*)["']/); + if (match) { + urls.push(match[1]); + } + } + return urls; + } + + /** + * Fires a "load" event for the given listener, using the current window + * + * @param {EventListener|Function} listener The event listener to call + */ + _fireEventListener(listener) { + let fakeEvent = new this.window.UIEvent("load", { view: this.window }); + if (typeof listener == "function") { + listener(fakeEvent); + } else if (listener && typeof listener == "object") { + listener.handleEvent(fakeEvent); + } else { + oconsole.error("Unknown listener type", listener); + } + } + + /** + * Resolves forward references for the given node. If the node exists in the target document, it + * is merged in with the target node. If the node has no id it is inserted at documentElement + * level. + * + * @param {Element} node The DOM Element to resolve in the target document. + * @returns {boolean} True, if the node was merged/inserted, false otherwise + */ + _resolveForwardReference(node) { + if (node.id) { + let target = this.document.getElementById(node.id); + if (node.localName == "toolbarpalette") { + let box; + if (target) { + box = target.closest("toolbox"); + } else { + // These vanish from the document but still exist via the palette property + let boxes = [...this.document.getElementsByTagName("toolbox")]; + box = boxes.find(box => box.palette && box.palette.id == node.id); + let palette = box ? box.palette : null; + + if (!palette) { + oconsole.debug( + `The palette for ${node.id} could not be found, deferring to later` + ); + return false; + } + + target = palette; + } + + this._toolbarsToResolve.push( + ...box.querySelectorAll('toolbar:not([type="menubar"])') + ); + } else if (!target) { + oconsole.debug( + `The node ${node.id} could not be found, deferring to later` + ); + return false; + } + + this._mergeElement(target, node); + } else { + this._insertElement(this.document.documentElement, node); + } + return true; + } + + /** + * Insert the node in the given parent, observing the insertbefore/insertafter/position attributes + * + * @param {Element} parent The parent element to insert the node into. + * @param {Element} node The node to insert. + */ + _insertElement(parent, node) { + // These elements need their values set before they are added to + // the document, or bad things happen. + for (let element of node.querySelectorAll("menulist, radiogroup")) { + if (element.id && this.persistedIDs.has(element.id)) { + element.setAttribute( + "value", + Services.xulStore.getValue(this.location, element.id, "value") + ); + } + } + + let wasInserted = false; + let pos = node.getAttribute("insertafter"); + let after = true; + + if (!pos) { + pos = node.getAttribute("insertbefore"); + after = false; + } + + if (pos) { + for (let id of pos.split(",")) { + let targetchild = this.document.getElementById(id); + if (targetchild && targetchild.parentNode == parent) { + parent.insertBefore( + node, + after ? targetchild.nextSibling : targetchild + ); + wasInserted = true; + break; + } + } + } + + if (!wasInserted) { + // position is 1-based + let position = parseInt(node.getAttribute("position"), 10); + if (position > 0 && position - 1 <= parent.childNodes.length) { + parent.insertBefore(node, parent.childNodes[position - 1]); + wasInserted = true; + } + } + + if (!wasInserted) { + parent.appendChild(node); + } + } + + /** + * Merge the node into the target, adhering to the removeelement attribute, merging further + * attributes into the target node, and merging children as appropriate for xul nodes. If a child + * has an id, it will be searched in the target document and recursively merged. + * + * @param {Element} target The node to merge into + * @param {Element} node The node that is being merged + */ + _mergeElement(target, node) { + for (let attribute of node.attributes) { + if (attribute.name == "id") { + continue; + } + + if (attribute.name == "removeelement" && attribute.value == "true") { + target.remove(); + return; + } + + target.setAttributeNS( + attribute.namespaceURI, + attribute.name, + attribute.value + ); + } + + for (let i = 0, len = node.childElementCount; i < len; i++) { + let child = node.firstElementChild; + child.remove(); + + let elementInDocument = child.id + ? this.document.getElementById(child.id) + : null; + let parentId = elementInDocument ? elementInDocument.parentNode.id : null; + + if (parentId && parentId == target.id) { + this._mergeElement(elementInDocument, child); + } else { + this._insertElement(target, child); + } + } + } + + /** + * Fetches the overlay from the given chrome:// or resource:// URL. This happen synchronously so + * we have a chance to complete before the load event. + * + * @param {string} srcUrl The URL to load + * @returns {XMLHttpRequest} The completed XHR. + */ + fetchOverlay(srcUrl) { + if (!srcUrl.startsWith("chrome://") && !srcUrl.startsWith("resource://")) { + throw new Error( + "May only load overlays from chrome:// or resource:// uris" + ); + } + + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("application/xml"); + xhr.open("GET", srcUrl, false); + + // Elevate the request, so DTDs will work. Should not be a security issue since we + // only load chrome, resource and file URLs, and that is our privileged chrome package. + try { + xhr.channel.owner = Services.scriptSecurityManager.getSystemPrincipal(); + } catch (ex) { + oconsole.error( + "Failed to set system principal while fetching overlay " + srcUrl + ); + xhr.close(); + throw new Error("Failed to set system principal"); + } + + xhr.send(null); + return xhr; + } + + /** + * Loads scripts described by the given script node. The node can either have a src attribute, or + * be an inline script with textContent. + * + * @param {Element} node The