Bug 972925 - Uplift Add-on SDK to Firefox
This commit is contained in:
@@ -8,15 +8,18 @@ module.metadata = {
|
||||
};
|
||||
|
||||
const observers = require('./system/events');
|
||||
const { Loader, validationAttributes } = require('./content/loader');
|
||||
const { contract: loaderContract } = require('./content/loader');
|
||||
const { contract } = require('./util/contract');
|
||||
const { getAttachEventType, WorkerHost } = require('./content/utils');
|
||||
const { Class } = require('./core/heritage');
|
||||
const { Disposable } = require('./core/disposable');
|
||||
const { Worker } = require('./content/worker');
|
||||
const { Registry } = require('./util/registry');
|
||||
const { EventEmitter } = require('./deprecated/events');
|
||||
const { on, emit } = require('./event/core');
|
||||
const { validateOptions : validate } = require('./deprecated/api-utils');
|
||||
const { Cc, Ci } = require('chrome');
|
||||
const { EventTarget } = require('./event/target');
|
||||
const { on, emit, once, setListeners } = require('./event/core');
|
||||
const { on: domOn, removeListener: domOff } = require('./dom/events');
|
||||
const { pipe } = require('./event/utils');
|
||||
const { isRegExp } = require('./lang/type');
|
||||
const { merge } = require('./util/object');
|
||||
const { readURISync } = require('./net/url');
|
||||
const { windowIterator } = require('./deprecated/window-utils');
|
||||
const { isBrowser, getFrames } = require('./window/utils');
|
||||
const { getTabs, getTabContentWindow, getTabForContentWindow,
|
||||
@@ -26,239 +29,141 @@ const { Style } = require("./stylesheet/style");
|
||||
const { attach, detach } = require("./content/mod");
|
||||
const { has, hasAny } = require("./util/array");
|
||||
const { Rules } = require("./util/rules");
|
||||
const { List, addListItem, removeListItem } = require('./util/list');
|
||||
const { when: unload } = require("./system/unload");
|
||||
|
||||
// Valid values for `attachTo` option
|
||||
const VALID_ATTACHTO_OPTIONS = ['existing', 'top', 'frame'];
|
||||
|
||||
const mods = new WeakMap();
|
||||
const pagemods = new Set();
|
||||
const workers = new WeakMap();
|
||||
const styles = new WeakMap();
|
||||
const models = new WeakMap();
|
||||
let modelFor = (mod) => models.get(mod);
|
||||
let workerFor = (mod) => workers.get(mod);
|
||||
let styleFor = (mod) => styles.get(mod);
|
||||
|
||||
// contentStyle* / contentScript* are sharing the same validation constraints,
|
||||
// so they can be mostly reused, except for the messages.
|
||||
const validStyleOptions = {
|
||||
contentStyle: merge(Object.create(validationAttributes.contentScript), {
|
||||
// Bind observer
|
||||
observers.on('document-element-inserted', onContentWindow);
|
||||
unload(() => observers.off('document-element-inserted', onContentWindow));
|
||||
|
||||
let isRegExpOrString = (v) => isRegExp(v) || typeof v === 'string';
|
||||
|
||||
// Validation Contracts
|
||||
const modOptions = {
|
||||
// contentStyle* / contentScript* are sharing the same validation constraints,
|
||||
// so they can be mostly reused, except for the messages.
|
||||
contentStyle: merge(Object.create(loaderContract.rules.contentScript), {
|
||||
msg: 'The `contentStyle` option must be a string or an array of strings.'
|
||||
}),
|
||||
contentStyleFile: merge(Object.create(validationAttributes.contentScriptFile), {
|
||||
contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), {
|
||||
msg: 'The `contentStyleFile` option must be a local URL or an array of URLs'
|
||||
})
|
||||
}),
|
||||
include: {
|
||||
is: ['string', 'array', 'regexp'],
|
||||
ok: (rule) => {
|
||||
if (isRegExpOrString(rule))
|
||||
return true;
|
||||
if (Array.isArray(rule) && rule.length > 0)
|
||||
return rule.every(isRegExpOrString);
|
||||
return false;
|
||||
},
|
||||
msg: 'The `include` option must always contain atleast one rule as a string, regular expression, or an array of strings and regular expressions.'
|
||||
},
|
||||
attachTo: {
|
||||
is: ['string', 'array', 'undefined'],
|
||||
map: function (attachTo) {
|
||||
if (!attachTo) return ['top', 'frame'];
|
||||
if (typeof attachTo === 'string') return [attachTo];
|
||||
return attachTo;
|
||||
},
|
||||
ok: function (attachTo) {
|
||||
return hasAny(attachTo, ['top', 'frame']) &&
|
||||
attachTo.every(has.bind(null, ['top', 'frame', 'existing']));
|
||||
},
|
||||
msg: 'The `attachTo` option must be a string or an array of strings. ' +
|
||||
'The only valid options are "existing", "top" and "frame", and must ' +
|
||||
'contain at least "top" or "frame" values.'
|
||||
},
|
||||
};
|
||||
|
||||
const modContract = contract(merge({}, loaderContract.rules, modOptions));
|
||||
|
||||
/**
|
||||
* PageMod constructor (exported below).
|
||||
* @constructor
|
||||
*/
|
||||
const PageMod = Loader.compose(EventEmitter, {
|
||||
on: EventEmitter.required,
|
||||
_listeners: EventEmitter.required,
|
||||
attachTo: [],
|
||||
contentScript: Loader.required,
|
||||
contentScriptFile: Loader.required,
|
||||
contentScriptWhen: Loader.required,
|
||||
contentScriptOptions: Loader.required,
|
||||
include: null,
|
||||
constructor: function PageMod(options) {
|
||||
this._onContent = this._onContent.bind(this);
|
||||
options = options || {};
|
||||
const PageMod = Class({
|
||||
implements: [
|
||||
modContract.properties(modelFor),
|
||||
EventTarget,
|
||||
Disposable
|
||||
],
|
||||
extends: WorkerHost(workerFor),
|
||||
setup: function PageMod(options) {
|
||||
let mod = this;
|
||||
let model = modContract(options);
|
||||
models.set(this, model);
|
||||
|
||||
let { contentStyle, contentStyleFile } = validate(options, validStyleOptions);
|
||||
// Set listeners on {PageMod} itself, not the underlying worker,
|
||||
// like `onMessage`, as it'll get piped.
|
||||
setListeners(this, options);
|
||||
|
||||
if ('contentScript' in options)
|
||||
this.contentScript = options.contentScript;
|
||||
if ('contentScriptFile' in options)
|
||||
this.contentScriptFile = options.contentScriptFile;
|
||||
if ('contentScriptOptions' in options)
|
||||
this.contentScriptOptions = options.contentScriptOptions;
|
||||
if ('contentScriptWhen' in options)
|
||||
this.contentScriptWhen = options.contentScriptWhen;
|
||||
if ('onAttach' in options)
|
||||
this.on('attach', options.onAttach);
|
||||
if ('onError' in options)
|
||||
this.on('error', options.onError);
|
||||
if ('attachTo' in options) {
|
||||
if (typeof options.attachTo == 'string')
|
||||
this.attachTo = [options.attachTo];
|
||||
else if (Array.isArray(options.attachTo))
|
||||
this.attachTo = options.attachTo;
|
||||
else
|
||||
throw new Error('The `attachTo` option must be a string or an array ' +
|
||||
'of strings.');
|
||||
let include = model.include;
|
||||
model.include = Rules();
|
||||
model.include.add.apply(model.include, [].concat(include));
|
||||
|
||||
let isValidAttachToItem = function isValidAttachToItem(item) {
|
||||
return typeof item === 'string' &&
|
||||
VALID_ATTACHTO_OPTIONS.indexOf(item) !== -1;
|
||||
}
|
||||
if (!this.attachTo.every(isValidAttachToItem))
|
||||
throw new Error('The `attachTo` option valid accept only following ' +
|
||||
'values: '+ VALID_ATTACHTO_OPTIONS.join(', '));
|
||||
if (!hasAny(this.attachTo, ["top", "frame"]))
|
||||
throw new Error('The `attachTo` option must always contain at least' +
|
||||
' `top` or `frame` value');
|
||||
}
|
||||
else {
|
||||
this.attachTo = ["top", "frame"];
|
||||
if (model.contentStyle || model.contentStyleFile) {
|
||||
styles.set(mod, Style({
|
||||
uri: model.contentStyleFile,
|
||||
source: model.contentStyle
|
||||
}));
|
||||
}
|
||||
|
||||
let include = options.include;
|
||||
let rules = this.include = Rules();
|
||||
pagemods.add(this);
|
||||
|
||||
if (!include)
|
||||
throw new Error('The `include` option must always contain atleast one rule');
|
||||
|
||||
rules.add.apply(rules, [].concat(include));
|
||||
|
||||
if (contentStyle || contentStyleFile) {
|
||||
this._style = Style({
|
||||
uri: contentStyleFile,
|
||||
source: contentStyle
|
||||
});
|
||||
}
|
||||
|
||||
this.on('error', this._onUncaughtError = this._onUncaughtError.bind(this));
|
||||
pageModManager.add(this._public);
|
||||
mods.set(this._public, this);
|
||||
|
||||
// `_applyOnExistingDocuments` has to be called after `pageModManager.add()`
|
||||
// otherwise its calls to `_onContent` method won't do anything.
|
||||
if ('attachTo' in options && has(options.attachTo, 'existing'))
|
||||
this._applyOnExistingDocuments();
|
||||
// `applyOnExistingDocuments` has to be called after `pagemods.add()`
|
||||
// otherwise its calls to `onContent` method won't do anything.
|
||||
if (has(model.attachTo, 'existing'))
|
||||
applyOnExistingDocuments(mod);
|
||||
},
|
||||
|
||||
destroy: function destroy() {
|
||||
if (this._style)
|
||||
detach(this._style);
|
||||
let style = styleFor(this);
|
||||
if (style)
|
||||
detach(style);
|
||||
|
||||
for (let i in this.include)
|
||||
this.include.remove(this.include[i]);
|
||||
|
||||
mods.delete(this._public);
|
||||
pageModManager.remove(this._public);
|
||||
},
|
||||
|
||||
_applyOnExistingDocuments: function _applyOnExistingDocuments() {
|
||||
let mod = this;
|
||||
let tabs = getAllTabs();
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
// Fake a newly created document
|
||||
let window = getTabContentWindow(tab);
|
||||
if (has(mod.attachTo, "top") && mod.include.matchesAny(getTabURI(tab)))
|
||||
mod._onContent(window);
|
||||
if (has(mod.attachTo, "frame")) {
|
||||
getFrames(window).
|
||||
filter((iframe) => mod.include.matchesAny(iframe.location.href)).
|
||||
forEach(mod._onContent);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_onContent: function _onContent(window) {
|
||||
// not registered yet
|
||||
if (!pageModManager.has(this))
|
||||
return;
|
||||
|
||||
let isTopDocument = window.top === window;
|
||||
// Is a top level document and `top` is not set, ignore
|
||||
if (isTopDocument && !has(this.attachTo, "top"))
|
||||
return;
|
||||
// Is a frame document and `frame` is not set, ignore
|
||||
if (!isTopDocument && !has(this.attachTo, "frame"))
|
||||
return;
|
||||
|
||||
if (this._style)
|
||||
attach(this._style, window);
|
||||
|
||||
// Immediatly evaluate content script if the document state is already
|
||||
// matching contentScriptWhen expectations
|
||||
let state = window.document.readyState;
|
||||
if ('start' === this.contentScriptWhen ||
|
||||
// Is `load` event already dispatched?
|
||||
'complete' === state ||
|
||||
// Is DOMContentLoaded already dispatched and waiting for it?
|
||||
('ready' === this.contentScriptWhen && state === 'interactive') ) {
|
||||
this._createWorker(window);
|
||||
return;
|
||||
}
|
||||
|
||||
let eventName = 'end' == this.contentScriptWhen ? 'load' : 'DOMContentLoaded';
|
||||
let self = this;
|
||||
window.addEventListener(eventName, function onReady(event) {
|
||||
if (event.target.defaultView != window)
|
||||
return;
|
||||
window.removeEventListener(eventName, onReady, true);
|
||||
|
||||
self._createWorker(window);
|
||||
}, true);
|
||||
},
|
||||
_createWorker: function _createWorker(window) {
|
||||
let worker = Worker({
|
||||
window: window,
|
||||
contentScript: this.contentScript,
|
||||
contentScriptFile: this.contentScriptFile,
|
||||
contentScriptOptions: this.contentScriptOptions,
|
||||
onError: this._onUncaughtError
|
||||
});
|
||||
this._emit('attach', worker);
|
||||
let self = this;
|
||||
worker.once('detach', function detach() {
|
||||
worker.destroy();
|
||||
});
|
||||
},
|
||||
_onUncaughtError: function _onUncaughtError(e) {
|
||||
if (this._listeners('error').length == 1)
|
||||
console.exception(e);
|
||||
pagemods.delete(this);
|
||||
}
|
||||
});
|
||||
exports.PageMod = function(options) PageMod(options)
|
||||
exports.PageMod.prototype = PageMod.prototype;
|
||||
exports.PageMod = PageMod;
|
||||
|
||||
const PageModManager = Registry.resolve({
|
||||
constructor: '_init',
|
||||
_destructor: '_registryDestructor'
|
||||
}).compose({
|
||||
constructor: function PageModRegistry(constructor) {
|
||||
this._init(PageMod);
|
||||
observers.on(
|
||||
'document-element-inserted',
|
||||
this._onContentWindow = this._onContentWindow.bind(this)
|
||||
);
|
||||
},
|
||||
_destructor: function _destructor() {
|
||||
observers.off('document-element-inserted', this._onContentWindow);
|
||||
this._removeAllListeners();
|
||||
function onContentWindow({ subject: document }) {
|
||||
// Return if we have no pagemods
|
||||
if (pagemods.size === 0)
|
||||
return;
|
||||
|
||||
// We need to do some cleaning er PageMods, like unregistering any
|
||||
// `contentStyle*`
|
||||
this._registry.forEach(function(pageMod) {
|
||||
pageMod.destroy();
|
||||
});
|
||||
let window = document.defaultView;
|
||||
// XML documents don't have windows, and we don't yet support them.
|
||||
if (!window)
|
||||
return;
|
||||
// We apply only on documents in tabs of Firefox
|
||||
if (!getTabForContentWindow(window))
|
||||
return;
|
||||
|
||||
this._registryDestructor();
|
||||
},
|
||||
_onContentWindow: function _onContentWindow({ subject: document }) {
|
||||
let window = document.defaultView;
|
||||
// XML documents don't have windows, and we don't yet support them.
|
||||
if (!window)
|
||||
return;
|
||||
// We apply only on documents in tabs of Firefox
|
||||
if (!getTabForContentWindow(window))
|
||||
return;
|
||||
// When the tab is private, only addons with 'private-browsing' flag in
|
||||
// their package.json can apply content script to private documents
|
||||
if (ignoreWindow(window))
|
||||
return;
|
||||
|
||||
// When the tab is private, only addons with 'private-browsing' flag in
|
||||
// their package.json can apply content script to private documents
|
||||
if (ignoreWindow(window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._registry.forEach(function(mod) {
|
||||
if (mod.include.matchesAny(document.URL))
|
||||
mods.get(mod)._onContent(window);
|
||||
});
|
||||
},
|
||||
off: function off(topic, listener) {
|
||||
this.removeListener(topic, listener);
|
||||
for (let pagemod of pagemods) {
|
||||
if (pagemod.include.matchesAny(document.URL))
|
||||
onContent(pagemod, window);
|
||||
}
|
||||
});
|
||||
const pageModManager = PageModManager();
|
||||
}
|
||||
|
||||
// Returns all tabs on all currently opened windows
|
||||
function getAllTabs() {
|
||||
@@ -271,3 +176,76 @@ function getAllTabs() {
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function applyOnExistingDocuments (mod) {
|
||||
let tabs = getAllTabs();
|
||||
|
||||
tabs.forEach(function (tab) {
|
||||
// Fake a newly created document
|
||||
let window = getTabContentWindow(tab);
|
||||
if (has(mod.attachTo, "top") && mod.include.matchesAny(getTabURI(tab)))
|
||||
onContent(mod, window);
|
||||
if (has(mod.attachTo, "frame")) {
|
||||
getFrames(window).
|
||||
filter((iframe) => mod.include.matchesAny(iframe.location.href)).
|
||||
forEach((frame) => onContent(mod, frame));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createWorker (mod, window) {
|
||||
let worker = Worker({
|
||||
window: window,
|
||||
contentScript: mod.contentScript,
|
||||
contentScriptFile: mod.contentScriptFile,
|
||||
contentScriptOptions: mod.contentScriptOptions,
|
||||
});
|
||||
workers.set(mod, worker);
|
||||
pipe(worker, mod);
|
||||
emit(mod, 'attach', worker);
|
||||
once(worker, 'detach', function detach() {
|
||||
worker.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function onContent (mod, window) {
|
||||
// not registered yet
|
||||
if (!pagemods.has(mod))
|
||||
return;
|
||||
|
||||
let isTopDocument = window.top === window;
|
||||
// Is a top level document and `top` is not set, ignore
|
||||
if (isTopDocument && !has(mod.attachTo, "top"))
|
||||
return;
|
||||
// Is a frame document and `frame` is not set, ignore
|
||||
if (!isTopDocument && !has(mod.attachTo, "frame"))
|
||||
return;
|
||||
|
||||
let style = styleFor(mod);
|
||||
if (style)
|
||||
attach(style, window);
|
||||
|
||||
// Immediatly evaluate content script if the document state is already
|
||||
// matching contentScriptWhen expectations
|
||||
if (isMatchingAttachState(mod, window)) {
|
||||
createWorker(mod, window);
|
||||
return;
|
||||
}
|
||||
|
||||
let eventName = getAttachEventType(mod) || 'load';
|
||||
domOn(window, eventName, function onReady (e) {
|
||||
if (e.target.defaultView !== window)
|
||||
return;
|
||||
domOff(window, eventName, onReady, true);
|
||||
createWorker(mod, window);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function isMatchingAttachState (mod, window) {
|
||||
let state = window.document.readyState;
|
||||
return 'start' === mod.contentScriptWhen ||
|
||||
// Is `load` event already dispatched?
|
||||
'complete' === state ||
|
||||
// Is DOMContentLoaded already dispatched and waiting for it?
|
||||
('ready' === mod.contentScriptWhen && state === 'interactive')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user