-
Notifications
You must be signed in to change notification settings - Fork 400
Expand file tree
/
Copy pathentry-userscripts.js
More file actions
294 lines (282 loc) · 9.52 KB
/
entry-userscripts.js
File metadata and controls
294 lines (282 loc) · 9.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
import USAPI from "./api.js";
import { colors } from "@shared/colors.js";
// code received from background page will be stored in this variable
// code referenced again when strict CSPs block initial injection attempt
let data;
// determines whether strict csp injection has already run (JS only)
let cspFallbackAttempted = false;
// label used to distinguish frames in console
const label = randomLabel();
const usTag = window.self === window.top ? "" : `(${label})`;
function randomLabel() {
const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const r = Math.random();
return a[Math.floor(r * a.length)] + r.toString().slice(5, 6);
}
function triageJS(userscript) {
const runAt = userscript.scriptObject["run-at"];
if (runAt === "document-start") {
injectJS(userscript);
} else if (runAt === "document-end") {
if (document.readyState !== "loading") {
injectJS(userscript);
} else {
document.addEventListener(
"DOMContentLoaded",
() => injectJS(userscript),
{ once: true },
);
}
} else if (runAt === "document-idle") {
if (document.readyState === "complete") {
injectJS(userscript);
} else {
const handle = () => {
if (document.readyState === "complete") {
injectJS(userscript);
document.removeEventListener("readystatechange", handle);
}
};
document.addEventListener("readystatechange", handle);
}
}
}
function injectJS(userscript) {
const filename = userscript.scriptObject.filename;
const name = userscript.scriptObject.name;
const code = `\
(async () => {
try {
// ===UserScript===start===
${userscript.code}
// ===UserScript====end====
} catch (error) {
console.error(\`${filename.replaceAll("`", "\\`")}\`, error);
}
})(); //# sourceURL=${filename.replace(/[\s"']/g, "-") + usTag}`;
let injectInto = userscript.scriptObject["inject-into"];
// change scope to content since strict CSP event detected
if (injectInto === "auto" && (userscript.fallback || cspFallbackAttempted)) {
injectInto = "content";
console.warn(`Attempting fallback injection for ${name}`);
}
const world = injectInto === "content" ? "content" : "page";
if (window.self === window.top) {
console.info(`Injecting: ${name} %c(js/${world})`, colors.yellow);
} else {
console.info(
`Injecting: ${name} %c(js/${world})%c - %cframe(${label})(${window.location})`,
colors.yellow,
colors.inherit,
colors.blue,
);
}
if (world === "page") {
const div = document.createElement("div");
div.style.display = "none";
const shadowRoot = div.attachShadow({ mode: "closed" });
const tag = document.createElement("script");
tag.textContent = code;
shadowRoot.append(tag);
(document.body ?? document.head ?? document.documentElement).append(div);
} else {
try {
Function(
`{${Object.keys(userscript.apis).join(",")}}`,
code,
)(userscript.apis);
} catch (error) {
console.error(`${filename}`, error);
}
return;
}
}
function injectCSS(name, code) {
if (window.self === window.top) {
console.info(`Injecting ${name} %c(css)`, colors.green);
} else {
console.info(
`Injecting ${name} %c(css)%c - %cframe(${label})(${window.location})`,
colors.green,
colors.inherit,
colors.blue,
);
}
// Safari lacks full support for tabs.insertCSS
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/insertCSS
// specifically frameId and cssOrigin
// if support for those details keys arrives, the method below can be used
// NOTE: manifest V3 does support frameId, but not origin
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/insertCSS
// write the css code to head of the document
const tag = document.createElement("style");
tag.textContent = code;
document.head.appendChild(tag);
}
function cspFallback(e) {
// if a security policy violation event has occurred
// and the directive is script-src or script-src-elem
// it's fair to assume that there is a strict CSP for javascript
// and that injection was blocked for all userscripts
// when any script-src violation is detected, re-attempt injection
if (
e.effectiveDirective === "script-src" ||
e.effectiveDirective === "script-src-elem"
) {
// get all "auto" code
// since other code can trigger a security policy violation event
// make sure data var is not undefined before attempting fallback
if (!data || cspFallbackAttempted) return;
// update global that tracks security policy violations
cspFallbackAttempted = true;
// for all userscripts with @inject-into: auto, attempt re-injection
for (let i = 0; i < data.files.js.length; i++) {
const userscript = data.files.js[i];
if (userscript.scriptObject["inject-into"] !== "auto") continue;
userscript.fallback = 1;
triageJS(userscript);
}
}
}
async function injection() {
const response = await browser.runtime.sendMessage({
name: "REQ_USERSCRIPTS",
});
if (import.meta.env.MODE === "development") {
console.debug("REQ_USERSCRIPTS", response);
}
// cancel injection if errors detected
if (!response || response.error) {
console.error(response?.error || "REQ_USERSCRIPTS returned undefined");
return;
}
// save response locally in case CSP events occur
data = response;
// combine regular and context-menu scripts
const scripts = [...data.files.js, ...data.files.menu];
// loop through each userscript and prepare for processing
for (let i = 0; i < scripts.length; i++) {
const userscript = scripts[i];
const filename = userscript.scriptObject.filename;
const grants = userscript.scriptObject.grant;
const injectInto = userscript.scriptObject["inject-into"];
// create GM.info object, all userscripts get access to GM.info
userscript.apis = { GM: {} };
userscript.apis.GM.info = {
script: userscript.scriptObject,
scriptHandler: data.scriptHandler,
scriptHandlerVersion: data.scriptHandlerVersion,
scriptMetaStr: userscript.scriptMetaStr,
version: data.scriptHandlerVersion,
};
// add GM_info
userscript.apis.GM_info = userscript.apis.GM.info;
// if @grant explicitly set to none, empty grants array
if (grants.includes("none")) grants.length = 0;
// @grant values exist for page scoped userscript
if (grants.length && injectInto === "page") {
// remove grants
grants.length = 0;
// log warning
console.warn(
`${filename} @grant values removed due to @inject-into value: ${injectInto} - https://github.com/quoid/userscripts/issues/265#issuecomment-1213462394`,
);
}
// @grant exist for auto scoped userscript
if (grants.length && injectInto === "auto") {
// change scope
userscript.scriptObject["inject-into"] = "content";
// log warning
console.warn(
`${filename} @inject-into value set to 'content' due to @grant values: ${grants} - https://github.com/quoid/userscripts/issues/265#issuecomment-1213462394`,
);
}
// loop through each userscript @grant value, add methods as needed
for (let j = 0; j < grants.length; j++) {
const grant = grants[j];
const method = grant.startsWith("GM.") ? grant.slice(3) : grant;
// ensure API method exists in USAPI object
if (!Object.keys(USAPI).includes(method)) continue;
// add granted methods
switch (method) {
case "info":
case "GM_info":
continue;
case "getValue":
case "setValue":
case "deleteValue":
case "listValues":
userscript.apis.GM[method] = USAPI[method].bind({
US_filename: filename,
});
break;
case "GM_xmlhttpRequest":
userscript.apis[method] = USAPI[method];
break;
default:
userscript.apis.GM[method] = USAPI[method];
}
}
// triage userjs item for injection
triageJS(userscript);
}
// loop through each usercss and inject
for (let i = 0; i < data.files.css.length; i++) {
const userstyle = data.files.css[i];
injectCSS(userstyle.name, userstyle.code);
}
}
function listeners() {
/** listen for CSP violations */
document.addEventListener("securitypolicyviolation", cspFallback, {
once: true,
});
/**
* listens for messages from background, popup, etc...
* @type {import("webextension-polyfill").Runtime.OnMessageListener}
*/
const handleMessage = (message) => {
const name = message.name;
if (name === "CONTEXT_RUN") {
// from bg script when context-menu item is clicked
// double check to ensure context-menu scripts only run in top windows
if (window !== window.top) return;
// loop through context-menu scripts saved to data object and find match
// if no match found, nothing will execute and error will log
const filename = message.menuItemId;
for (let i = 0; i < data.files.menu.length; i++) {
const item = data.files.menu[i];
if (item.scriptObject.filename === filename) {
console.info(`Injecting ${filename} %c(js)`, colors.yellow);
injectJS(item);
return;
}
}
console.error(`Couldn't find ${filename} code!`);
}
};
/** Dynamically remove listeners to avoid memory leaks */
if (document.visibilityState === "visible") {
browser.runtime.onMessage.addListener(handleMessage);
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
browser.runtime.onMessage.removeListener(handleMessage);
} else {
browser.runtime.onMessage.addListener(handleMessage);
}
});
}
async function initialize() {
// avoid duplicate injection of content scripts
if (window["CS_ENTRY_USERSCRIPTS"]) return;
window["CS_ENTRY_USERSCRIPTS"] = 1;
// check user settings
const key = "US_GLOBAL_ACTIVE";
const results = await browser.storage.local.get(key);
if (results[key] === false) return console.info("Userscripts off");
// start the injection process and add the listeners
injection();
listeners();
}
initialize();