263 lines
8.7 KiB
JavaScript
263 lines
8.7 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 { 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 { WeakReference } = require('./core/reference');
|
|
const { Worker } = require('./content/worker');
|
|
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, isUndefined } = require('./lang/type');
|
|
const { merge } = require('./util/object');
|
|
const { windowIterator } = require('./deprecated/window-utils');
|
|
const { isBrowser, getFrames } = require('./window/utils');
|
|
const { getTabs, getTabContentWindow, getTabForContentWindow,
|
|
getURI: getTabURI } = require('./tabs/utils');
|
|
const { ignoreWindow } = require('./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");
|
|
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 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);
|
|
|
|
// Bind observer
|
|
observers.on('document-element-inserted', onContentWindow);
|
|
unload(() => observers.off('document-element-inserted', onContentWindow));
|
|
|
|
// Helper functions
|
|
let isRegExpOrString = (v) => isRegExp(v) || typeof v === 'string';
|
|
let modMatchesURI = (mod, uri) => mod.include.matchesAny(uri) && !mod.exclude.matchesAny(uri);
|
|
|
|
// 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(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.'
|
|
},
|
|
exclude: {
|
|
is: ['string', 'array', 'regexp', 'undefined'],
|
|
ok: (rule) => {
|
|
if (isRegExpOrString(rule) || isUndefined(rule))
|
|
return true;
|
|
if (Array.isArray(rule) && rule.length > 0)
|
|
return rule.every(isRegExpOrString);
|
|
return false;
|
|
},
|
|
msg: 'If set, the `exclude` option must always contain at least 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 = Class({
|
|
implements: [
|
|
modContract.properties(modelFor),
|
|
EventTarget,
|
|
Disposable,
|
|
WeakReference
|
|
],
|
|
extends: WorkerHost(workerFor),
|
|
setup: function PageMod(options) {
|
|
let mod = this;
|
|
let model = modContract(options);
|
|
models.set(this, model);
|
|
|
|
// Set listeners on {PageMod} itself, not the underlying worker,
|
|
// like `onMessage`, as it'll get piped.
|
|
setListeners(this, options);
|
|
|
|
let include = model.include;
|
|
model.include = Rules();
|
|
model.include.add.apply(model.include, [].concat(include));
|
|
|
|
let exclude = isUndefined(model.exclude) ? [] : model.exclude;
|
|
model.exclude = Rules();
|
|
model.exclude.add.apply(model.exclude, [].concat(exclude));
|
|
|
|
if (model.contentStyle || model.contentStyleFile) {
|
|
styles.set(mod, Style({
|
|
uri: model.contentStyleFile,
|
|
source: model.contentStyle
|
|
}));
|
|
}
|
|
|
|
pagemods.add(this);
|
|
|
|
// `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);
|
|
},
|
|
|
|
dispose: function() {
|
|
let style = styleFor(this);
|
|
if (style)
|
|
detach(style);
|
|
|
|
for (let i in this.include)
|
|
this.include.remove(this.include[i]);
|
|
|
|
pagemods.delete(this);
|
|
}
|
|
});
|
|
exports.PageMod = PageMod;
|
|
|
|
function onContentWindow({ subject: document }) {
|
|
// Return if we have no pagemods
|
|
if (pagemods.size === 0)
|
|
return;
|
|
|
|
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;
|
|
|
|
for (let pagemod of pagemods) {
|
|
if (modMatchesURI(pagemod, document.URL))
|
|
onContent(pagemod, window);
|
|
}
|
|
}
|
|
|
|
function applyOnExistingDocuments (mod) {
|
|
getTabs().forEach(tab => {
|
|
// Fake a newly created document
|
|
let window = getTabContentWindow(tab);
|
|
let uri = getTabURI(tab);
|
|
if (has(mod.attachTo, "top") && modMatchesURI(mod, uri))
|
|
onContent(mod, window);
|
|
if (has(mod.attachTo, "frame"))
|
|
getFrames(window).
|
|
filter(iframe => modMatchesURI(mod, 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,
|
|
// Bug 980468: Syntax errors from scripts can happen before the worker
|
|
// can set up an error handler. They are per-mod rather than per-worker
|
|
// so are best handled at the mod level.
|
|
onError: (e) => emit(mod, 'error', e)
|
|
});
|
|
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')
|
|
}
|