/* 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";
// eslint-disable-next-line mozilla/reject-importGlobalProperties
Cu.importGlobalProperties([
"crypto",
"TextEncoder"]);
var { DefaultMap, ExtensionError, getUniqueId } = ExtensionUtils;
/**
* Represents a registered userScript in the child extension process.
*
* @param {ExtensionPageContextChild} context
* The extension context which has registered the user script.
* @param {string} scriptId
* An unique id that represents the registered user script
* (generated and used internally to identify it across the different processes).
*/
class UserScriptChild {
constructor({ context, scriptId, onScriptUnregister }) {
this.context = context;
this.scriptId = scriptId;
this.onScriptUnregister = onScriptUnregister;
this.unregistered =
false;
}
async unregister() {
if (
this.unregistered) {
throw new ExtensionError(
"User script already unregistered");
}
this.unregistered =
true;
await
this.context.childManager.callParentAsyncFunction(
"userScripts.unregister",
[
this.scriptId]
);
this.context =
null;
this.onScriptUnregister();
}
api() {
const { context } =
this;
// Returns the object with the "unregister" method for the legacy
// MV2-only userScripts.register() method.
return {
unregister: () => {
return context.wrapPromise(
this.unregister());
},
};
}
}
this.userScripts =
class extends ExtensionAPI {
getAPI(context) {
if (context.extension.manifestVersion !==
2) {
// MV2 needs special argument processing, MV3 does not.
return {};
}
// Cache of the script code already converted into blob urls:
// Map<textHash, blobURLs>
const blobURLsByHash =
new Map();
// Keep track of the userScript that are sharing the same blob urls,
// so that we can revoke any blob url that is not used by a registered
// userScripts:
// Map<blobURL, Set<scriptId>>
const userScriptsByBlobURL =
new DefaultMap(() =>
new Set());
function revokeBlobURLs(scriptId, options) {
let revokedUrls =
new Set();
for (let url of options.js) {
if (userScriptsByBlobURL.has(url)) {
let scriptIds = userScriptsByBlobURL.get(url);
scriptIds.
delete(scriptId);
if (scriptIds.size ===
0) {
revokedUrls.add(url);
userScriptsByBlobURL.
delete(url);
context.cloneScope.URL.revokeObjectURL(url);
}
}
}
// Remove all the removed urls from the map of known computed hashes.
for (let [hash, url] of blobURLsByHash) {
if (revokedUrls.has(url)) {
blobURLsByHash.
delete(hash);
}
}
}
// Convert a script code string into a blob URL (and use a cached one
// if the script hash is already associated to a blob URL).
const getBlobURL = async (text, scriptId) => {
// Compute the hash of the js code string and reuse the blob url if we already have
// for the same hash.
const buffer = await crypto.subtle.digest(
"SHA-1",
new TextEncoder().encode(text)
);
const hash = String.fromCharCode(...
new Uint16Array(buffer));
let blobURL = blobURLsByHash.get(hash);
if (blobURL) {
userScriptsByBlobURL.get(blobURL).add(scriptId);
return blobURL;
}
const blob =
new context.cloneScope.Blob([text], {
type:
"text/javascript",
});
blobURL = context.cloneScope.URL.createObjectURL(blob);
// Start to track this blob URL.
userScriptsByBlobURL.get(blobURL).add(scriptId);
blobURLsByHash.set(hash, blobURL);
return blobURL;
};
function convertToAPIObject(scriptId, options) {
const registeredScript =
new UserScriptChild({
context,
scriptId,
onScriptUnregister: () => revokeBlobURLs(scriptId, options),
});
const scriptAPI = Cu.cloneInto(
registeredScript.api(),
context.cloneScope,
{ cloneFunctions:
true }
);
return scriptAPI;
}
// Revoke all the created blob urls once the context is destroyed.
context.callOnClose({
close() {
if (!context.cloneScope) {
return;
}
for (let blobURL of blobURLsByHash.values()) {
context.cloneScope.URL.revokeObjectURL(blobURL);
}
},
});
return {
userScripts: {
register(options) {
let scriptId = getUniqueId();
return context.cloneScope.Promise.resolve().then(async () => {
options.scriptId = scriptId;
options.js = await Promise.all(
options.js.map(js => {
return js.file || getBlobURL(js.code, scriptId);
})
);
await context.childManager.callParentAsyncFunction(
"userScripts.register",
[options]
);
return convertToAPIObject(scriptId, options);
});
},
},
};
}
};