|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 module.metadata = { |
|
7 "stability": "stable" |
|
8 }; |
|
9 |
|
10 const observers = require('./system/events'); |
|
11 const { contract: loaderContract } = require('./content/loader'); |
|
12 const { contract } = require('./util/contract'); |
|
13 const { getAttachEventType, WorkerHost } = require('./content/utils'); |
|
14 const { Class } = require('./core/heritage'); |
|
15 const { Disposable } = require('./core/disposable'); |
|
16 const { WeakReference } = require('./core/reference'); |
|
17 const { Worker } = require('./content/worker'); |
|
18 const { EventTarget } = require('./event/target'); |
|
19 const { on, emit, once, setListeners } = require('./event/core'); |
|
20 const { on: domOn, removeListener: domOff } = require('./dom/events'); |
|
21 const { pipe } = require('./event/utils'); |
|
22 const { isRegExp } = require('./lang/type'); |
|
23 const { merge } = require('./util/object'); |
|
24 const { windowIterator } = require('./deprecated/window-utils'); |
|
25 const { isBrowser, getFrames } = require('./window/utils'); |
|
26 const { getTabs, getTabContentWindow, getTabForContentWindow, |
|
27 getURI: getTabURI } = require('./tabs/utils'); |
|
28 const { ignoreWindow } = require('sdk/private-browsing/utils'); |
|
29 const { Style } = require("./stylesheet/style"); |
|
30 const { attach, detach } = require("./content/mod"); |
|
31 const { has, hasAny } = require("./util/array"); |
|
32 const { Rules } = require("./util/rules"); |
|
33 const { List, addListItem, removeListItem } = require('./util/list'); |
|
34 const { when: unload } = require("./system/unload"); |
|
35 |
|
36 // Valid values for `attachTo` option |
|
37 const VALID_ATTACHTO_OPTIONS = ['existing', 'top', 'frame']; |
|
38 |
|
39 const pagemods = new Set(); |
|
40 const workers = new WeakMap(); |
|
41 const styles = new WeakMap(); |
|
42 const models = new WeakMap(); |
|
43 let modelFor = (mod) => models.get(mod); |
|
44 let workerFor = (mod) => workers.get(mod); |
|
45 let styleFor = (mod) => styles.get(mod); |
|
46 |
|
47 // Bind observer |
|
48 observers.on('document-element-inserted', onContentWindow); |
|
49 unload(() => observers.off('document-element-inserted', onContentWindow)); |
|
50 |
|
51 let isRegExpOrString = (v) => isRegExp(v) || typeof v === 'string'; |
|
52 |
|
53 // Validation Contracts |
|
54 const modOptions = { |
|
55 // contentStyle* / contentScript* are sharing the same validation constraints, |
|
56 // so they can be mostly reused, except for the messages. |
|
57 contentStyle: merge(Object.create(loaderContract.rules.contentScript), { |
|
58 msg: 'The `contentStyle` option must be a string or an array of strings.' |
|
59 }), |
|
60 contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), { |
|
61 msg: 'The `contentStyleFile` option must be a local URL or an array of URLs' |
|
62 }), |
|
63 include: { |
|
64 is: ['string', 'array', 'regexp'], |
|
65 ok: (rule) => { |
|
66 if (isRegExpOrString(rule)) |
|
67 return true; |
|
68 if (Array.isArray(rule) && rule.length > 0) |
|
69 return rule.every(isRegExpOrString); |
|
70 return false; |
|
71 }, |
|
72 msg: 'The `include` option must always contain atleast one rule as a string, regular expression, or an array of strings and regular expressions.' |
|
73 }, |
|
74 attachTo: { |
|
75 is: ['string', 'array', 'undefined'], |
|
76 map: function (attachTo) { |
|
77 if (!attachTo) return ['top', 'frame']; |
|
78 if (typeof attachTo === 'string') return [attachTo]; |
|
79 return attachTo; |
|
80 }, |
|
81 ok: function (attachTo) { |
|
82 return hasAny(attachTo, ['top', 'frame']) && |
|
83 attachTo.every(has.bind(null, ['top', 'frame', 'existing'])); |
|
84 }, |
|
85 msg: 'The `attachTo` option must be a string or an array of strings. ' + |
|
86 'The only valid options are "existing", "top" and "frame", and must ' + |
|
87 'contain at least "top" or "frame" values.' |
|
88 }, |
|
89 }; |
|
90 |
|
91 const modContract = contract(merge({}, loaderContract.rules, modOptions)); |
|
92 |
|
93 /** |
|
94 * PageMod constructor (exported below). |
|
95 * @constructor |
|
96 */ |
|
97 const PageMod = Class({ |
|
98 implements: [ |
|
99 modContract.properties(modelFor), |
|
100 EventTarget, |
|
101 Disposable, |
|
102 WeakReference |
|
103 ], |
|
104 extends: WorkerHost(workerFor), |
|
105 setup: function PageMod(options) { |
|
106 let mod = this; |
|
107 let model = modContract(options); |
|
108 models.set(this, model); |
|
109 |
|
110 // Set listeners on {PageMod} itself, not the underlying worker, |
|
111 // like `onMessage`, as it'll get piped. |
|
112 setListeners(this, options); |
|
113 |
|
114 let include = model.include; |
|
115 model.include = Rules(); |
|
116 model.include.add.apply(model.include, [].concat(include)); |
|
117 |
|
118 if (model.contentStyle || model.contentStyleFile) { |
|
119 styles.set(mod, Style({ |
|
120 uri: model.contentStyleFile, |
|
121 source: model.contentStyle |
|
122 })); |
|
123 } |
|
124 |
|
125 pagemods.add(this); |
|
126 |
|
127 // `applyOnExistingDocuments` has to be called after `pagemods.add()` |
|
128 // otherwise its calls to `onContent` method won't do anything. |
|
129 if (has(model.attachTo, 'existing')) |
|
130 applyOnExistingDocuments(mod); |
|
131 }, |
|
132 |
|
133 dispose: function() { |
|
134 let style = styleFor(this); |
|
135 if (style) |
|
136 detach(style); |
|
137 |
|
138 for (let i in this.include) |
|
139 this.include.remove(this.include[i]); |
|
140 |
|
141 pagemods.delete(this); |
|
142 } |
|
143 }); |
|
144 exports.PageMod = PageMod; |
|
145 |
|
146 function onContentWindow({ subject: document }) { |
|
147 // Return if we have no pagemods |
|
148 if (pagemods.size === 0) |
|
149 return; |
|
150 |
|
151 let window = document.defaultView; |
|
152 // XML documents don't have windows, and we don't yet support them. |
|
153 if (!window) |
|
154 return; |
|
155 // We apply only on documents in tabs of Firefox |
|
156 if (!getTabForContentWindow(window)) |
|
157 return; |
|
158 |
|
159 // When the tab is private, only addons with 'private-browsing' flag in |
|
160 // their package.json can apply content script to private documents |
|
161 if (ignoreWindow(window)) |
|
162 return; |
|
163 |
|
164 for (let pagemod of pagemods) { |
|
165 if (pagemod.include.matchesAny(document.URL)) |
|
166 onContent(pagemod, window); |
|
167 } |
|
168 } |
|
169 |
|
170 // Returns all tabs on all currently opened windows |
|
171 function getAllTabs() { |
|
172 let tabs = []; |
|
173 // Iterate over all chrome windows |
|
174 for (let window in windowIterator()) { |
|
175 if (!isBrowser(window)) |
|
176 continue; |
|
177 tabs = tabs.concat(getTabs(window)); |
|
178 } |
|
179 return tabs; |
|
180 } |
|
181 |
|
182 function applyOnExistingDocuments (mod) { |
|
183 let tabs = getAllTabs(); |
|
184 |
|
185 tabs.forEach(function (tab) { |
|
186 // Fake a newly created document |
|
187 let window = getTabContentWindow(tab); |
|
188 if (has(mod.attachTo, "top") && mod.include.matchesAny(getTabURI(tab))) |
|
189 onContent(mod, window); |
|
190 if (has(mod.attachTo, "frame")) { |
|
191 getFrames(window). |
|
192 filter((iframe) => mod.include.matchesAny(iframe.location.href)). |
|
193 forEach((frame) => onContent(mod, frame)); |
|
194 } |
|
195 }); |
|
196 } |
|
197 |
|
198 function createWorker (mod, window) { |
|
199 let worker = Worker({ |
|
200 window: window, |
|
201 contentScript: mod.contentScript, |
|
202 contentScriptFile: mod.contentScriptFile, |
|
203 contentScriptOptions: mod.contentScriptOptions, |
|
204 // Bug 980468: Syntax errors from scripts can happen before the worker |
|
205 // can set up an error handler. They are per-mod rather than per-worker |
|
206 // so are best handled at the mod level. |
|
207 onError: (e) => emit(mod, 'error', e) |
|
208 }); |
|
209 workers.set(mod, worker); |
|
210 pipe(worker, mod); |
|
211 emit(mod, 'attach', worker); |
|
212 once(worker, 'detach', function detach() { |
|
213 worker.destroy(); |
|
214 }); |
|
215 } |
|
216 |
|
217 function onContent (mod, window) { |
|
218 // not registered yet |
|
219 if (!pagemods.has(mod)) |
|
220 return; |
|
221 |
|
222 let isTopDocument = window.top === window; |
|
223 // Is a top level document and `top` is not set, ignore |
|
224 if (isTopDocument && !has(mod.attachTo, "top")) |
|
225 return; |
|
226 // Is a frame document and `frame` is not set, ignore |
|
227 if (!isTopDocument && !has(mod.attachTo, "frame")) |
|
228 return; |
|
229 |
|
230 let style = styleFor(mod); |
|
231 if (style) |
|
232 attach(style, window); |
|
233 |
|
234 // Immediatly evaluate content script if the document state is already |
|
235 // matching contentScriptWhen expectations |
|
236 if (isMatchingAttachState(mod, window)) { |
|
237 createWorker(mod, window); |
|
238 return; |
|
239 } |
|
240 |
|
241 let eventName = getAttachEventType(mod) || 'load'; |
|
242 domOn(window, eventName, function onReady (e) { |
|
243 if (e.target.defaultView !== window) |
|
244 return; |
|
245 domOff(window, eventName, onReady, true); |
|
246 createWorker(mod, window); |
|
247 }, true); |
|
248 } |
|
249 |
|
250 function isMatchingAttachState (mod, window) { |
|
251 let state = window.document.readyState; |
|
252 return 'start' === mod.contentScriptWhen || |
|
253 // Is `load` event already dispatched? |
|
254 'complete' === state || |
|
255 // Is DOMContentLoaded already dispatched and waiting for it? |
|
256 ('ready' === mod.contentScriptWhen && state === 'interactive') |
|
257 } |