Files
tubestation/addon-sdk/source/lib/sdk/page-mod.js
2014-01-16 17:29:40 -08:00

274 lines
9.1 KiB
JavaScript

/* 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";
module.metadata = {
"stability": "stable"
};
const observers = require('./system/events');
const { Loader, validationAttributes } = require('./content/loader');
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 { 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,
getURI: getTabURI } = require('./tabs/utils');
const { ignoreWindow } = require('sdk/private-browsing/utils');
const { Style } = require("./stylesheet/style");
const { attach, detach } = require("./content/mod");
const { has, hasAny } = require("./util/array");
const { Rules } = require("./util/rules");
// Valid values for `attachTo` option
const VALID_ATTACHTO_OPTIONS = ['existing', 'top', 'frame'];
const mods = new WeakMap();
// 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), {
msg: 'The `contentStyle` option must be a string or an array of strings.'
}),
contentStyleFile: merge(Object.create(validationAttributes.contentScriptFile), {
msg: 'The `contentStyleFile` option must be a local URL or an array of URLs'
})
};
/**
* 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 || {};
let { contentStyle, contentStyleFile } = validate(options, validStyleOptions);
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 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"];
}
let include = options.include;
let rules = this.include = Rules();
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();
},
destroy: function destroy() {
if (this._style)
detach(this._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);
}
});
exports.PageMod = function(options) PageMod(options)
exports.PageMod.prototype = PageMod.prototype;
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();
// We need to do some cleaning er PageMods, like unregistering any
// `contentStyle*`
this._registry.forEach(function(pageMod) {
pageMod.destroy();
});
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;
}
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);
}
});
const pageModManager = PageModManager();
// Returns all tabs on all currently opened windows
function getAllTabs() {
let tabs = [];
// Iterate over all chrome windows
for (let window in windowIterator()) {
if (!isBrowser(window))
continue;
tabs = tabs.concat(getTabs(window));
}
return tabs;
}