/* * This file is provided by the addon-developer-support repository at * https://github.com/thundernest/addon-developer-support * * Version: 1.20 * - fix long delay before customize window opens * - fix non working removal of palette items * * Version: 1.19 * - add support for ToolbarPalette * * Version: 1.18 * - execute shutdown script also during global app shutdown (fixed) * * Version: 1.17 * - execute shutdown script also during global app shutdown * * Version: 1.16 * - support for persist * * Version: 1.15 * - make (undocumented) startup() async * * Version: 1.14 * - support resource urls * * Version: 1.12 * - no longer allow to enforce custom "namespace" * - no longer call it namespace but uniqueRandomID / scopeName * - expose special objects as the global WL object * - autoremove injected elements after onUnload has ben executed * * Version: 1.9 * - automatically remove all entries added by injectElements * * Version: 1.8 * - add injectElements * * Version: 1.7 * - add injectCSS * - add optional enforced namespace * * Version: 1.6 * - added mutation observer to be able to inject into browser elements * - use larger icons as fallback * * Author: John Bieling (john@thunderbird.net) * * 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 some things we need. var { ExtensionCommon } = ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm"); var { ExtensionSupport } = ChromeUtils.import("resource:///modules/ExtensionSupport.jsm"); var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); var WindowListener = class extends ExtensionCommon.ExtensionAPI { getAPI(context) { // track if this is the background/main context this.isBackgroundContext = (context.viewType == "background"); this.uniqueRandomID = "AddOnNS" + context.extension.instanceId; this.menu_addonsManager_id ="addonsManager"; this.menu_addonsManager_prefs_id = "addonsManager_prefs_revived"; this.menu_addonPrefs_id = "addonPrefs_revived"; this.registeredWindows = {}; this.pathToStartupScript = null; this.pathToShutdownScript = null; this.pathToOptionsPage = null; this.chromeHandle = null; this.chromeData = null; this.resourceData = null; this.openWindows = []; const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(Ci.amIAddonManagerStartup); const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); let self = this; return { WindowListener: { registerOptionsPage(optionsUrl) { self.pathToOptionsPage = optionsUrl.startsWith("chrome://") ? optionsUrl : context.extension.rootURI.resolve(optionsUrl); }, registerDefaultPrefs(defaultUrl) { let url = context.extension.rootURI.resolve(defaultUrl); let prefsObj = {}; prefsObj.Services = ChromeUtils.import("resource://gre/modules/Services.jsm").Services; prefsObj.pref = function(aName, aDefault) { let defaults = Services.prefs.getDefaultBranch(""); switch (typeof aDefault) { case "string": return defaults.setCharPref(aName, aDefault); case "number": return defaults.setIntPref(aName, aDefault); case "boolean": return defaults.setBoolPref(aName, aDefault); default: throw new Error("Preference <" + aName + "> has an unsupported type <" + typeof aDefault + ">. Allowed are string, number and boolean."); } } Services.scriptloader.loadSubScript(url, prefsObj, "UTF-8"); }, registerChromeUrl(data) { if (!self.isBackgroundContext) throw new Error("The WindowListener API may only be called from the background page."); let chromeData = []; let resourceData = []; for (let entry of data) { if (entry[0] == "resource") resourceData.push(entry); else chromeData.push(entry) } if (chromeData.length > 0) { const manifestURI = Services.io.newURI( "manifest.json", null, context.extension.rootURI ); self.chromeHandle = aomStartup.registerChrome(manifestURI, chromeData); } for (let res of resourceData) { // [ "resource", "shortname" , "path" ] let uri = Services.io.newURI( res[2], null, context.extension.rootURI ); resProto.setSubstitutionWithFlags( res[1], uri, resProto.ALLOW_CONTENT_ACCESS ); } self.chromeData = chromeData; self.resourceData = resourceData; }, registerWindow(windowHref, jsFile) { if (!self.isBackgroundContext) throw new Error("The WindowListener API may only be called from the background page."); if (!self.registeredWindows.hasOwnProperty(windowHref)) { // path to JS file can either be chrome:// URL or a relative URL let path = jsFile.startsWith("chrome://") ? jsFile : context.extension.rootURI.resolve(jsFile) self.registeredWindows[windowHref] = path; } else { console.error("Window <" +windowHref + "> has already been registered"); } }, registerStartupScript(aPath) { if (!self.isBackgroundContext) throw new Error("The WindowListener API may only be called from the background page."); self.pathToStartupScript = aPath.startsWith("chrome://") ? aPath : context.extension.rootURI.resolve(aPath); }, registerShutdownScript(aPath) { if (!self.isBackgroundContext) throw new Error("The WindowListener API may only be called from the background page."); self.pathToShutdownScript = aPath.startsWith("chrome://") ? aPath : context.extension.rootURI.resolve(aPath); }, async startListening() { // async sleep function using Promise async function sleep(delay) { let timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); return new Promise(function(resolve, reject) { let event = { notify: function(timer) { resolve(); } } timer.initWithCallback(event, delay, Components.interfaces.nsITimer.TYPE_ONE_SHOT); }); }; if (!self.isBackgroundContext) throw new Error("The WindowListener API may only be called from the background page."); // load the registered startup script, if one has been registered // (mail3:pane may not have been fully loaded yet) if (self.pathToStartupScript) { let startupJS = {}; startupJS.WL = {} startupJS.WL.extension = self.extension; startupJS.WL.messenger = Array.from(self.extension.views).find( view => view.viewType === "background").xulBrowser.contentWindow .wrappedJSObject.browser; try { if (self.pathToStartupScript) { Services.scriptloader.loadSubScript(self.pathToStartupScript, startupJS, "UTF-8"); // delay startup until startup has been finished console.log("Waiting for async startup() in <" + self.pathToStartupScript + "> to finish."); if (startupJS.startup) { await startupJS.startup(); console.log("startup() in <" + self.pathToStartupScript + "> finished"); } else { console.log("No startup() in <" + self.pathToStartupScript + "> found."); } } } catch (e) { Components.utils.reportError(e) } } let urls = Object.keys(self.registeredWindows); if (urls.length > 0) { // Before registering the window listener, check which windows are already open self.openWindows = []; for (let window of Services.wm.getEnumerator(null)) { self.openWindows.push(window); } // Register window listener for all pre-registered windows ExtensionSupport.registerWindowListener("injectListener_" + self.uniqueRandomID, { // React on all windows and manually reduce to the registered // windows, so we can do special actions when the main // messenger window is opened. //chromeURLs: Object.keys(self.registeredWindows), async onLoadWindow(window) { // Create add-on scope window[self.uniqueRandomID] = {}; // Special action #1: If this is the main messenger window if (window.location.href == "chrome://messenger/content/messenger.xul" || window.location.href == "chrome://messenger/content/messenger.xhtml") { if (self.pathToOptionsPage) { try { // add the add-on options menu if needed if (!window.document.getElementById(self.menu_addonsManager_prefs_id)) { let addonprefs = window.MozXULElement.parseXULToFragment(`
`, ["chrome://messenger/locale/messenger.dtd"]); let element_addonsManager = window.document.getElementById(self.menu_addonsManager_id); element_addonsManager.parentNode.insertBefore(addonprefs, element_addonsManager.nextSibling); } // add the options entry let element_addonPrefs = window.document.getElementById(self.menu_addonPrefs_id); let id = self.menu_addonPrefs_id + "_" + self.uniqueRandomID; // Get the best size of the icon (16px or bigger) let iconSizes = Object.keys(self.extension.manifest.icons); iconSizes.sort((a,b)=>a-b); let bestSize = iconSizes.filter(e => parseInt(e) >= 16).shift(); let icon = bestSize ? self.extension.manifest.icons[bestSize] : ""; let name = self.extension.manifest.name; let entry = window.MozXULElement.parseXULToFragment( ``); element_addonPrefs.appendChild(entry); window.document.getElementById(id).addEventListener("command", function() {window.openDialog(self.pathToOptionsPage, "AddonOptions")}); } catch (e) { Components.utils.reportError(e) } } } // Special action #2: If this page contains browser elements let browserElements = window.document.getElementsByTagName("browser"); if (browserElements.length > 0) { //register a MutationObserver window[self.uniqueRandomID]._mObserver = new window.MutationObserver(function(mutations) { mutations.forEach(async function(mutation) { if (mutation.attributeName == "src" && self.registeredWindows.hasOwnProperty(mutation.target.getAttribute("src"))) { // When the MutationObserver callsback, the window is still showing "about:black" and it is going // to unload and then load the new page. Any eventListener attached to the window will be removed // so we cannot listen for the load event. We have to poll manually to learn when loading has finished. // On my system it takes 70ms. let loaded = false; for (let i=0; i < 100 && !loaded; i++) { await sleep(100); let targetWindow = mutation.target.contentWindow.wrappedJSObject; if (targetWindow && targetWindow.location.href == mutation.target.getAttribute("src") && targetWindow.document.readyState == "complete") { loaded = true; break; } } if (loaded) { let targetWindow = mutation.target.contentWindow.wrappedJSObject; // Create add-on scope targetWindow[self.uniqueRandomID] = {}; // Inject with isAddonActivation = false self._loadIntoWindow(targetWindow, false); } } }); }); for (let element of browserElements) { if (self.registeredWindows.hasOwnProperty(element.getAttribute("src"))) { let targetWindow = element.contentWindow.wrappedJSObject; // Create add-on scope targetWindow[self.uniqueRandomID] = {}; // Inject with isAddonActivation = true self._loadIntoWindow(targetWindow, true); } else { // Window/Browser is not yet fully loaded, postpone injection via MutationObserver window[self.uniqueRandomID]._mObserver.observe(element, { attributes: true, childList: false, characterData: false }); } } } // Load JS into window self._loadIntoWindow(window, self.openWindows.includes(window)); }, onUnloadWindow(window) { // Remove JS from window, window is being closed, addon is not shut down self._unloadFromWindow(window, false); } }); } else { console.error("Failed to start listening, no windows registered"); } }, } }; } _loadIntoWindow(window, isAddonActivation) { if (window.hasOwnProperty(this.uniqueRandomID) && this.registeredWindows.hasOwnProperty(window.location.href)) { try { let uniqueRandomID = this.uniqueRandomID; // Add reference to window to add-on scope window[this.uniqueRandomID].window = window; window[this.uniqueRandomID].document = window.document; // Keep track of toolbarpalettes we are injecting into window[this.uniqueRandomID]._toolbarpalettes = {}; //Create WLDATA object window[this.uniqueRandomID].WL = {}; window[this.uniqueRandomID].WL.scopeName = this.uniqueRandomID; // Add helper function to inject CSS to WLDATA object window[this.uniqueRandomID].WL.injectCSS = function (cssFile) { let element; let v = parseInt(Services.appinfo.version.split(".").shift()); // using createElementNS in TB78 delays the insert process and hides any security violation errors if (v > 68) { element = window.document.createElement("link"); } else { let ns = window.document.documentElement.lookupNamespaceURI("html"); element = window.document.createElementNS(ns, "link"); } element.setAttribute("wlapi_autoinjected", uniqueRandomID); element.setAttribute("rel", "stylesheet"); element.setAttribute("href", cssFile); return window.document.documentElement.appendChild(element); } // Add helper function to inject XUL to WLDATA object window[this.uniqueRandomID].WL.injectElements = function (xulString, dtdFiles = [], debug = false) { let toolbarsToResolve = []; function checkElements(stringOfIDs) { let arrayOfIDs = stringOfIDs.split(",").map(e => e.trim()); for (let id of arrayOfIDs) { let element = window.document.getElementById(id); if (element) { return element; } } return null; } function injectChildren(elements, container) { if (debug) console.log(elements); for (let i = 0; i < elements.length; i++) { // take care of persists const uri = window.document.documentURI; for (const persistentNode of elements[i].querySelectorAll("[persist]")) { for (const persistentAttribute of persistentNode.getAttribute("persist").trim().split(" ")) { if (Services.xulStore.hasValue(uri, persistentNode.id, persistentAttribute)) { persistentNode.setAttribute( persistentAttribute, Services.xulStore.getValue(uri, persistentNode.id, persistentAttribute) ); } } } if (elements[i].hasAttribute("insertafter") && checkElements(elements[i].getAttribute("insertafter"))) { let insertAfterElement = checkElements(elements[i].getAttribute("insertafter")); if (debug) console.log(elements[i].tagName + "#" + elements[i].id + ": insertafter " + insertAfterElement.id); if (elements[i].id && window.document.getElementById(elements[i].id)) { console.error("The id <" + elements[i].id + "> of the injected element already exists in the document!"); } elements[i].setAttribute("wlapi_autoinjected", uniqueRandomID); insertAfterElement.parentNode.insertBefore(elements[i], insertAfterElement.nextSibling); } else if (elements[i].hasAttribute("insertbefore") && checkElements(elements[i].getAttribute("insertbefore"))) { let insertBeforeElement = checkElements(elements[i].getAttribute("insertbefore")); if (debug) console.log(elements[i].tagName + "#" + elements[i].id + ": insertbefore " + insertBeforeElement.id); if (elements[i].id && window.document.getElementById(elements[i].id)) { console.error("The id <" + elements[i].id + "> of the injected element already exists in the document!"); } elements[i].setAttribute("wlapi_autoinjected", uniqueRandomID); insertBeforeElement.parentNode.insertBefore(elements[i], insertBeforeElement); } else if (elements[i].id && window.document.getElementById(elements[i].id)) { // existing container match, dive into recursivly if (debug) console.log(elements[i].tagName + "#" + elements[i].id + " is an existing container, injecting into " + elements[i].id); injectChildren(Array.from(elements[i].children), window.document.getElementById(elements[i].id)); } else if (elements[i].localName === "toolbarpalette") { // These vanish from the document but still exist via the palette property if (debug) console.log(elements[i].id + " is a toolbarpalette"); let boxes = [...window.document.getElementsByTagName("toolbox")]; let box = boxes.find(box => box.palette && box.palette.id === elements[i].id); let palette = box ? box.palette : null; if (!palette) { if (debug) console.log(`The palette for ${elements[i].id} could not be found, deferring to later`); continue; } if (debug) console.log(`The toolbox for ${elements[i].id} is ${box.id}`); toolbarsToResolve.push(...box.querySelectorAll("toolbar")); toolbarsToResolve.push(...window.document.querySelectorAll(`toolbar[toolboxid="${box.id}"]`)); for (let child of elements[i].children) { child.setAttribute("wlapi_autoinjected", uniqueRandomID); } window[uniqueRandomID]._toolbarpalettes[palette.id] = palette; injectChildren(Array.from(elements[i].children), palette); } else { // append element to the current container if (debug) console.log(elements[i].tagName + "#" + elements[i].id + ": append to " + container.id); elements[i].setAttribute("wlapi_autoinjected", uniqueRandomID); container.appendChild(elements[i]); } } } if (debug) console.log ("Injecting into root document:"); injectChildren(Array.from(window.MozXULElement.parseXULToFragment(xulString, dtdFiles).children), window.document.documentElement); for (let bar of toolbarsToResolve) { let currentset = Services.xulStore.getValue( window.location, bar.id, "currentset" ); if (currentset) { bar.currentSet = currentset; } else if (bar.getAttribute("defaultset")) { bar.currentSet = bar.getAttribute("defaultset"); } } } // Add extension object to WLDATA object window[this.uniqueRandomID].WL.extension = this.extension; // Add messenger object to WLDATA object window[this.uniqueRandomID].WL.messenger = Array.from(this.extension.views).find( view => view.viewType === "background").xulBrowser.contentWindow .wrappedJSObject.browser; // Load script into add-on scope Services.scriptloader.loadSubScript(this.registeredWindows[window.location.href], window[this.uniqueRandomID], "UTF-8"); window[this.uniqueRandomID].onLoad(isAddonActivation); } catch (e) { Components.utils.reportError(e) } } } _unloadFromWindow(window, isAddonDeactivation) { // unload any contained browser elements if (window.hasOwnProperty(this.uniqueRandomID) && window[this.uniqueRandomID].hasOwnProperty("_mObserver")) { window[this.uniqueRandomID]._mObserver.disconnect(); let browserElements = window.document.getElementsByTagName("browser"); for (let element of browserElements) { this._unloadFromWindow(element.contentWindow.wrappedJSObject, isAddonDeactivation); } } if (window.hasOwnProperty(this.uniqueRandomID) && this.registeredWindows.hasOwnProperty(window.location.href)) { // Remove this window from the list of open windows this.openWindows = this.openWindows.filter(e => (e != window)); if (window[this.uniqueRandomID].onUnload) { try { // Call onUnload() window[this.uniqueRandomID].onUnload(isAddonDeactivation); } catch (e) { Components.utils.reportError(e) } } // Remove all auto injected objects let elements = Array.from(window.document.querySelectorAll('[wlapi_autoinjected="' + this.uniqueRandomID + '"]')); for (let element of elements) { element.remove(); } // Remove all autoinjected toolbarpalette items for (const palette of Object.values(window[this.uniqueRandomID]._toolbarpalettes)) { let elements = Array.from(palette.querySelectorAll('[wlapi_autoinjected="' + this.uniqueRandomID + '"]')); for (let element of elements) { element.remove(); } } } // Remove add-on scope, if it exists if (window.hasOwnProperty(this.uniqueRandomID)) { delete window[this.uniqueRandomID]; } } onShutdown(isAppShutdown) { // Unload from all still open windows let urls = Object.keys(this.registeredWindows); if (urls.length > 0) { for (let window of Services.wm.getEnumerator(null)) { //remove our entry in the add-on options menu if ( this.pathToOptionsPage && (window.location.href == "chrome://messenger/content/messenger.xul" || window.location.href == "chrome://messenger/content/messenger.xhtml")) { let id = this.menu_addonPrefs_id + "_" + this.uniqueRandomID; window.document.getElementById(id).remove(); //do we have to remove the entire add-on options menu? let element_addonPrefs = window.document.getElementById(this.menu_addonPrefs_id); if (element_addonPrefs.children.length == 0) { window.document.getElementById(this.menu_addonsManager_prefs_id).remove(); } } // if it is app shutdown, it is not just an add-on deactivation this._unloadFromWindow(window, !isAppShutdown); } // Stop listening for new windows. ExtensionSupport.unregisterWindowListener("injectListener_" + this.uniqueRandomID); } // Load registered shutdown script let shutdownJS = {}; shutdownJS.extension = this.extension; try { if (this.pathToShutdownScript) Services.scriptloader.loadSubScript(this.pathToShutdownScript, shutdownJS, "UTF-8"); } catch (e) { Components.utils.reportError(e) } // Extract all registered chrome content urls let chromeUrls = []; if (this.chromeData) { for (let chromeEntry of this.chromeData) { if (chromeEntry[0].toLowerCase().trim() == "content") { chromeUrls.push("chrome://" + chromeEntry[1] + "/"); } } } // Unload JSMs of this add-on const rootURI = this.extension.rootURI.spec; for (let module of Cu.loadedModules) { if (module.startsWith(rootURI) || (module.startsWith("chrome://") && chromeUrls.find(s => module.startsWith(s)))) { console.log("Unloading: " + module); Cu.unload(module); } } // Flush all caches Services.obs.notifyObservers(null, "startupcache-invalidate"); this.registeredWindows = {}; if (this.resourceData) { const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService(Ci.nsISubstitutingProtocolHandler); for (let res of this.resourceData) { // [ "resource", "shortname" , "path" ] resProto.setSubstitution( res[1], null, ); } } if (this.chromeHandle) { this.chromeHandle.destruct(); this.chromeHandle = null; } } };