Revision control
Copy as Markdown
Other Tools
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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,
"use strict";
var { getUniqueId } = ExtensionUtils;
const scripts = new Set();
ExtensionSupport.registerWindowListener("ext-composeScripts", {
chromeURLs: [
],
onLoadWindow: async win => {
// The editor is not loading any content but just about:blank, so its
// readyState is "complete" already when we get here. The editor is modified
// according to the provided properties (reply, draft etc.) afterwards. The
// injection point "document_start" would be a good fit here directly, but it
// currently fails in ExtensionContent.sys.mjs, where the child actor does not
// find the editor window:
// Calls to script.executeInWindow() succeed only after waiting for the
// compose-editor-ready event.
await new Promise(resolve =>
win.addEventListener("compose-editor-ready", resolve, { once: true })
);
// Even after this point, the document could be modified by the compose API.
// We currently do not have a notification once *all* modifications to the
// editor are done. And it is probably difficult to get right.
for (const script of scripts) {
if (script.type == "compose") {
script.executeInWindow(
win,
script.extension.tabManager.getWrapper(win)
);
}
}
},
});
ExtensionSupport.registerWindowListener("ext-messageDisplayScripts", {
chromeURLs: [
],
onLoadWindow(win) {
win.addEventListener("MsgLoading", event => {
// `event.target` is an about:message window.
const nativeTab = event.target.tabOrWindow;
for (const script of scripts) {
if (script.type == "messageDisplay") {
// Each script will be injected according to its runAt value.
script.executeInWindow(
win,
script.extension.tabManager.wrapTab(nativeTab)
);
}
}
});
},
});
/**
* Represents (in the main browser process) a script registered
* programmatically (instead of being included in the addon manifest).
*
* @param {ProxyContextParent} context
* The parent proxy context related to the extension context which
* has registered the script.
* @param {RegisteredScriptOptions} details
* The options object related to the registered script
* (which has the properties described in the extensionScripts.json
* JSON API schema file).
*/
class ExtensionScriptParent {
/**
* @param {"compose"|"messageDisplay"} type - Type of script.
* @param {object} contextOrExtension - Either a ProxyContextParent (for
* dynamically registered scripts) or an Extension (for manifest scripts).
* When an Extension is passed, the script is not tied to a context
* lifecycle and must be removed from the scripts Set manually.
* @param {object} details - Script options (js, css, runAt, etc.)
*/
constructor(type, contextOrExtension, details) {
this.type = type;
this._parentContext = null;
if (contextOrExtension.uri) {
// ProxyContextParent: has .extension, .uri, .callOnClose
this._parentContext = contextOrExtension;
this.context = contextOrExtension;
this.extension = contextOrExtension.extension;
contextOrExtension.callOnClose(this);
} else {
// Extension object directly (manifest scripts): create a minimal
// context with the properties needed by tab.insertCSS/executeScript.
this.context = {
uri: contextOrExtension.baseURI,
extension: contextOrExtension,
};
this.extension = contextOrExtension;
}
this.scriptId = getUniqueId();
this.options = this._convertOptions(details);
scripts.add(this);
}
close() {
this.destroy();
}
destroy() {
if (this.destroyed) {
throw new ExtensionError("Unable to destroy ExtensionScriptParent twice");
}
scripts.delete(this);
this.destroyed = true;
if (this._parentContext) {
this._parentContext.forgetOnClose(this);
this._parentContext = null;
}
this.context = null;
this.options = null;
}
_convertOptions(details) {
const options = {
js: [],
css: [],
runAt: details?.runAt ?? "document_idle",
};
if (details.js && details.js.length) {
options.js = details.js.map(data => {
return {
code: data.code || null,
file: data.file || null,
};
});
}
if (details.css && details.css.length) {
options.css = details.css.map(data => {
return {
code: data.code || null,
file: data.file || null,
};
});
}
return options;
}
async executeInWindow(window, tab) {
for (const css of this.options.css) {
await tab.insertCSS(this.context, {
...css,
frameId: null,
runAt: this.options.runAt,
});
}
for (const js of this.options.js) {
await tab.executeScript(this.context, {
...js,
frameId: null,
runAt: this.options.runAt,
});
}
window.dispatchEvent(
new window.CustomEvent("extension-scripts-added", {
detail: { runAt: this.options.runAt },
})
);
}
}
this.extensionScripts = class extends ExtensionAPI {
onManifestEntry(entryName) {
const scriptType =
entryName === "compose_scripts" ? "compose" : "messageDisplay";
const entries = this.extension.manifest[entryName];
if (!entries) {
return;
}
if (!this._manifestScripts) {
this._manifestScripts = [];
// Use callOnClose on the extension to ensure scripts are removed from
// the scripts Set before the extension policy is destroyed, preventing
// injection attempts into a torn-down extension.
this.extension.callOnClose({
close: () => {
for (const script of this._manifestScripts) {
scripts.delete(script);
}
this._manifestScripts = null;
},
});
}
for (const entry of entries) {
// Manifest entries use ExtensionURL (strings), but _convertOptions
// expects { file, code } objects.
const details = {
js: (entry.js || []).map(file => ({ file })),
css: (entry.css || []).map(file => ({ file })),
runAt: entry.run_at || "document_idle",
};
const script = new ExtensionScriptParent(
scriptType,
this.extension,
details
);
this._manifestScripts.push(script);
}
}
getAPI(context) {
// Map of the script registered from the extension context.
//
// Map<scriptId -> ExtensionScriptParent>
const parentScriptsMap = new Map();
// Unregister all the scriptId related to a context when it is closed.
context.callOnClose({
close() {
for (const script of parentScriptsMap.values()) {
script.destroy();
}
parentScriptsMap.clear();
},
});
return {
extensionScripts: {
async register(type, details) {
const script = new ExtensionScriptParent(type, context, details);
const { scriptId } = script;
parentScriptsMap.set(scriptId, script);
return scriptId;
},
// This method is not available to the extension code, the extension code
// doesn't have access to the internally used scriptId, on the contrary
// the extension code will call script.unregister on the script API object
// that is resolved from the register API method returned promise.
async unregister(scriptId) {
const script = parentScriptsMap.get(scriptId);
if (!script) {
console.error(new ExtensionError(`No such script ID: ${scriptId}`));
return;
}
parentScriptsMap.delete(scriptId);
script.destroy();
},
},
};
}
};