624 lines
19 KiB
JavaScript
624 lines
19 KiB
JavaScript
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set sts=2 sw=2 et tw=80: */
|
|
/* 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/. */
|
|
/* eslint-disable mozilla/valid-lazy */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = XPCOMUtils.declareLazy({
|
|
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
|
|
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
|
|
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
|
|
KeyValueService: "resource://gre/modules/kvstore.sys.mjs",
|
|
MENU_STORE_WRITE_DEBOUNCE_TIME: {
|
|
pref: "extensions.webextensions.menus.writeDebounceTime",
|
|
// Minimum 0ms, max 1min
|
|
transform: value => Math.min(Math.max(value, 0), 1 * 60 * 1000),
|
|
default: 5000, // Default to 5s
|
|
},
|
|
});
|
|
|
|
const SCHEMA_VERSION = 1;
|
|
const KVSTORE_DIRNAME = "extension-store-menus";
|
|
|
|
/**
|
|
* MenuId represent the type of the ids associated to the extension
|
|
* created menus, which is expected to be of type:
|
|
*
|
|
* - string
|
|
* - or an auto-generated integer (for menus created without a pre-assigned menu id,
|
|
* only allowed for extensions with a persistent background script or without any background
|
|
* context at all).
|
|
*
|
|
* Only menus registered by extensions with a non-persistent backgrond context are going
|
|
* to be persisted across sessions, and their id is always a string.
|
|
*
|
|
* @typedef {number} integer
|
|
* @typedef {string|integer} MenuId
|
|
*
|
|
|
|
/**
|
|
* This class manages the extensions menus stored on disk across
|
|
* all extensions (with kvstore as the underlying backend).
|
|
*/
|
|
class ExtensionMenusStore {
|
|
#store = null;
|
|
#initPromise = null;
|
|
|
|
/**
|
|
* Determine if the menus created by the given extension should
|
|
* be persisted on disk.
|
|
*
|
|
* @param {Extension} extension
|
|
*
|
|
* @returns {boolean} Returns true if the menus should be persisted on disk.
|
|
*/
|
|
static shouldPersistMenus(extension) {
|
|
return extension.manifest.background && !extension.persistentBackground;
|
|
}
|
|
|
|
get storePath() {
|
|
const { path: storePath } = lazy.FileUtils.getDir("ProfD", [
|
|
KVSTORE_DIRNAME,
|
|
]);
|
|
return storePath;
|
|
}
|
|
|
|
async #asyncInit() {
|
|
const { storePath } = this;
|
|
await IOUtils.makeDirectory(storePath, { ignoreExisting: true });
|
|
this.#store = await lazy.KeyValueService.getOrCreateWithOptions(
|
|
storePath,
|
|
"menus",
|
|
{ strategy: lazy.KeyValueService.RecoveryStrategy.RENAME }
|
|
);
|
|
}
|
|
|
|
#lazyInit() {
|
|
if (!this.#initPromise) {
|
|
this.#initPromise = this.#asyncInit();
|
|
}
|
|
|
|
return this.#initPromise;
|
|
}
|
|
|
|
#notifyPersistedMenusUpdatedForTesting(extensionId) {
|
|
Services.obs.notifyObservers(
|
|
null,
|
|
"webext-persisted-menus-updated",
|
|
extensionId
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An helper method to check if the store includes data for the given extension (ID).
|
|
*
|
|
* @param {string} extensionId An extension ID
|
|
* @returns {Promise<boolean>} true if the store includes data for the given
|
|
* extension, false otherwise.
|
|
*/
|
|
async hasExtensionData(extensionId) {
|
|
await this.#lazyInit();
|
|
return this.#store.has(extensionId);
|
|
}
|
|
|
|
/**
|
|
* Returns all the persisted menus for a given extension (ID), or an empty map
|
|
* if there isn't any data for the given extension id and extension version.
|
|
*
|
|
* @param {string} extensionId An extension ID
|
|
* @param {string} currentExtensionVersion The current extension version
|
|
* @returns {Promise<Map>} A map of persisted menu details.
|
|
*/
|
|
async getPersistedMenus(extensionId, currentExtensionVersion) {
|
|
await this.#lazyInit();
|
|
|
|
let value;
|
|
try {
|
|
value = await this.#store.get(extensionId);
|
|
} catch (err) {
|
|
Cu.reportError(
|
|
`Error on retrieving stored menus for ${extensionId}: ${err}\n`
|
|
);
|
|
}
|
|
|
|
if (!value) {
|
|
return new Map();
|
|
}
|
|
|
|
const { menuSchemaVersion, extensionVersion, menus } = JSON.parse(value);
|
|
|
|
// Drop the stored data if the extension version is not matching the
|
|
// current extension version.
|
|
if (extensionVersion != currentExtensionVersion) {
|
|
return new Map();
|
|
}
|
|
|
|
// NOTE: future version may use the following block to convert stored menus
|
|
// data.
|
|
if (menuSchemaVersion !== SCHEMA_VERSION) {
|
|
Cu.reportError(
|
|
`Dropping stored menus for ${extensionId} due to unxpected menuSchemaVersion ${menuSchemaVersion} (expected ${SCHEMA_VERSION})`
|
|
);
|
|
// TODO: should we consider firing onInstalled if we had to drop stored
|
|
// menus due to a schema mismatch? should we do the same in case of
|
|
// corrupted storage?
|
|
return new Map();
|
|
}
|
|
|
|
return new Map(menus);
|
|
}
|
|
|
|
/**
|
|
* Updates the map of persisted menus for a given extension (ID).
|
|
*
|
|
* We store each menu registered by extensions as an array of
|
|
* key/value pairs (derived from the Map of MenuCreateProperties).
|
|
*
|
|
* The format on disk should look like this one:
|
|
*
|
|
* ```
|
|
* {
|
|
* "@extension-id1": { menuSchemaVersion: N, menus: [["menuid-1", <MenuCreateProperties>], ...] },
|
|
* "@extension-id2": { menuSchemaVersion: N, menus: [["menuid-2", <MenuCreateProperties>], ...] },
|
|
* }
|
|
* ```
|
|
*
|
|
* @param {string} extensionId An extension ID
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async updatePersistedMenus(extensionId, extensionVersion, menusMap) {
|
|
await this.#lazyInit();
|
|
await this.#store.put(
|
|
extensionId,
|
|
JSON.stringify({
|
|
menuSchemaVersion: SCHEMA_VERSION,
|
|
extensionVersion: extensionVersion,
|
|
menus: Array.from(menusMap.entries()),
|
|
})
|
|
);
|
|
this.#notifyPersistedMenusUpdatedForTesting(extensionId);
|
|
}
|
|
|
|
/**
|
|
* Clears all the menus persisted on disk for a given extension (ID)
|
|
* being uninstalled.
|
|
*
|
|
* @param {string} extensionId An extension ID
|
|
*/
|
|
async clearPersistedMenusOnUninstall(extensionId) {
|
|
const { storePath } = this;
|
|
const kvstoreDirExists = await IOUtils.exists(storePath);
|
|
if (!kvstoreDirExists) {
|
|
// Avoid to create an unnecessary kvstore directory (through the call
|
|
// to lazyInit). If one doesn't already, then there isn't any data
|
|
// to clear.
|
|
return;
|
|
}
|
|
|
|
await this.#lazyInit();
|
|
await this.#store.delete(extensionId);
|
|
this.#notifyPersistedMenusUpdatedForTesting(extensionId);
|
|
}
|
|
}
|
|
|
|
let store = new ExtensionMenusStore();
|
|
|
|
/**
|
|
* This class manages the extensions menus for a specific extension.
|
|
*
|
|
* For extensions with a persistent background extension context
|
|
* or without any background extension the menus are kept only in memory,
|
|
* whereas for extensions with a non persistent background context
|
|
* the menus are also persisted on disk.
|
|
*/
|
|
class ExtensionMenusManager {
|
|
#writeToStoreTask = null;
|
|
#shutdownBlockerFn = null;
|
|
|
|
constructor(extension) {
|
|
if (extension.hasShutdown) {
|
|
throw new Error(
|
|
`Error on creating new ExtensionMenusManager after extension shutdown: ${extension.id}`
|
|
);
|
|
}
|
|
this.extensionId = extension.id;
|
|
this.extensionVersion = extension.version;
|
|
this.persistMenusData = ExtensionMenusStore.shouldPersistMenus(extension);
|
|
// Map[MenuId -> MenuCreateProperties]
|
|
this.menus = null;
|
|
if (this.persistMenusData) {
|
|
extension.callOnClose(this);
|
|
}
|
|
}
|
|
|
|
async _finalizeStoreTaskForTesting() {
|
|
if (this.#writeToStoreTask && !this.#writeToStoreTask.isFinalized) {
|
|
await this.#writeToStoreTask;
|
|
}
|
|
}
|
|
|
|
close() {
|
|
if (!this.#shutdownBlockerFn) {
|
|
return;
|
|
}
|
|
|
|
const shutdownBlockerFn = this.#shutdownBlockerFn;
|
|
shutdownBlockerFn().then(() => {
|
|
lazy.AsyncShutdown.profileBeforeChange.removeBlocker(shutdownBlockerFn);
|
|
});
|
|
}
|
|
|
|
async asyncInit() {
|
|
if (this.menus) {
|
|
// ExtensionMenusManager is expected to be called only once from
|
|
// ExtensionMenus.asyncInitForExtension, which is expected to be
|
|
// called only once per extension (from the ext-menus onStartup
|
|
// lifecycle method).
|
|
Cu.reportError(
|
|
`ExtensionMenusManager for ${this.extensionId} should not be initialized more than once`
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!this.persistMenusData) {
|
|
this.menus = new Map();
|
|
}
|
|
|
|
this.menus = await store
|
|
.getPersistedMenus(this.extensionId, this.extensionVersion)
|
|
.catch(err => {
|
|
Cu.reportError(
|
|
`Error loading ${this.extensionId} persisted menus: ${err}`
|
|
);
|
|
return new Map();
|
|
});
|
|
}
|
|
|
|
#ensureInitialized() {
|
|
// ExtensionMenusStore instance for each extension using the menus API
|
|
// is expected to be done from the menus API onStartup lifecycle method.
|
|
if (!this.menus) {
|
|
throw new Error(
|
|
`ExtensionMenusStore instance for ${this.extensionId} is not initialized`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronously retrieve the map of the extension menus.
|
|
*
|
|
* For extensions that should persist menus across sessions the map is
|
|
* initialized from the data stored on disk and so this method is expected
|
|
* to only be called after ext-menus onStartup lifecycle method have
|
|
* called asyncInit on the instance of this class.
|
|
*
|
|
* @returns {Map<MenuId, object>} Map of the menus createProperties keyed by
|
|
* menu id.
|
|
*/
|
|
getMenus() {
|
|
this.#ensureInitialized();
|
|
return this.menus;
|
|
}
|
|
|
|
/**
|
|
* Add or update menu data and optionally reparent menus (used when the update to
|
|
* a menu includes a different parentId). A DeferredTask scheduled by this
|
|
* method will update all menus data stored on disk for extensions that should
|
|
* persist menus across sessions.
|
|
*
|
|
* @param {object} menuDetails The createProperties for the menu
|
|
* to add or update.
|
|
* @param {boolean} [reparent=false] Set to true if the menu should also
|
|
* be reparented.
|
|
*/
|
|
updateMenus(menuDetails, reparent = false) {
|
|
this.#ensureInitialized();
|
|
|
|
if (this.persistMenusData && reparent) {
|
|
// Make sure the reparent menu item is appended at the end (and so for sure
|
|
// after the menu item that will become its new parent).
|
|
// This is necessary if menuDetails.parentId is set (because it may point
|
|
// to a menu entry that appears after the current entry in the Map), but we
|
|
// still do it unconditionally (even if parentId is null) to make sure the
|
|
// relocated menu item is always rendered at the bottom.
|
|
this.menus.delete(menuDetails.id);
|
|
}
|
|
this.menus.set(menuDetails.id, menuDetails);
|
|
|
|
if (!this.persistMenusData) {
|
|
return;
|
|
}
|
|
|
|
if (reparent) {
|
|
// The menu items are ordered, with child menu items always following its parent.
|
|
// The logic below moves menu item registrations as needed to ensure a consistent order.
|
|
let menuIds = new Set();
|
|
menuIds.add(menuDetails.id);
|
|
// Iterate over a copy of the entries because we may modify the menus Map.
|
|
for (let [id, menuCreateDetails] of Array.from(this.menus)) {
|
|
if (menuIds.has(menuCreateDetails.parentId)) {
|
|
// Remember menu ID to detect its children.
|
|
menuIds.add(id);
|
|
// Append menu items to the end, to ensure that child menu items always follow the parent.
|
|
this.menus.delete(id);
|
|
this.menus.set(id, menuCreateDetails);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.#scheduleWriteToStoreTask();
|
|
}
|
|
|
|
/**
|
|
* Delete the given menu ids. A DeferredTask will update all menus
|
|
* data stored on disk for extensions that should persist menus across sessions.
|
|
*
|
|
* @param {Array<MenuId>} menuIds Array of menu ids to remove.
|
|
*/
|
|
deleteMenus(menuIds) {
|
|
this.#ensureInitialized();
|
|
for (const menuId of menuIds) {
|
|
this.menus.delete(menuId);
|
|
}
|
|
if (!this.persistMenusData) {
|
|
return;
|
|
}
|
|
this.#scheduleWriteToStoreTask();
|
|
}
|
|
|
|
/**
|
|
* Delete all menus. A DeferredTask scheduled by this method will update all menus
|
|
* data stored on disk for extensions that should persist menus across sessions.
|
|
*/
|
|
deleteAllMenus() {
|
|
this.#ensureInitialized();
|
|
let alreadyEmpty = !this.menus.size;
|
|
this.menus.clear();
|
|
if (!this.persistMenusData || alreadyEmpty) {
|
|
return;
|
|
}
|
|
this.#scheduleWriteToStoreTask();
|
|
}
|
|
|
|
#scheduleWriteToStoreTask() {
|
|
this.#ensureInitialized();
|
|
if (!this.#writeToStoreTask) {
|
|
this.#writeToStoreTask = new lazy.DeferredTask(
|
|
() => this.#writeToStoreNow(),
|
|
lazy.MENU_STORE_WRITE_DEBOUNCE_TIME
|
|
);
|
|
this.#shutdownBlockerFn = async () => {
|
|
if (!this.#writeToStoreTask || this.#writeToStoreTask.isFinalized) {
|
|
return;
|
|
}
|
|
await this.#writeToStoreTask.finalize();
|
|
this.#writeToStoreTask = null;
|
|
this.#shutdownBlockerFn = null;
|
|
};
|
|
lazy.AsyncShutdown.profileBeforeChange.addBlocker(
|
|
`Flush "${this.extensionId}" persisted menus to disk`,
|
|
this.#shutdownBlockerFn
|
|
);
|
|
}
|
|
this.#writeToStoreTask.arm();
|
|
}
|
|
|
|
async #writeToStoreNow() {
|
|
this.#ensureInitialized();
|
|
await store.updatePersistedMenus(
|
|
this.extensionId,
|
|
this.extensionVersion,
|
|
this.menus
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Singleton providing a collection of methods used by
|
|
* ext-menus.js (and tests) to interact with the underlying classes.
|
|
*/
|
|
export const ExtensionMenus = {
|
|
KVSTORE_DIRNAME,
|
|
|
|
// WeakMap<Extension, { promise: Promise<ExtensionMenusManager>, instance: ExtensionMenusManager}>
|
|
_menusManagers: new WeakMap(),
|
|
|
|
/**
|
|
* Determine if the menus created by the given extension should
|
|
* be persisted on disk.
|
|
*
|
|
* @param {Extension} extension
|
|
*
|
|
* @returns {boolean} Returns true if the menus should be persisted on disk.
|
|
*/
|
|
shouldPersistMenus(extension) {
|
|
return ExtensionMenusStore.shouldPersistMenus(extension);
|
|
},
|
|
|
|
/**
|
|
* Create and initialize ExtensionMenusManager instance
|
|
* for the given extension. Used by ext-menus.js onStartup
|
|
* lifecycle method.
|
|
*
|
|
* @param {Extension} extension
|
|
*
|
|
* @returns {Promise<void>} A promise resolved when the
|
|
* ExtensionMenusManager instance is fully initialized
|
|
* (and persisted menus data loaded from disk for the
|
|
* extensions with a non-persistent background script).
|
|
*/
|
|
async asyncInitForExtension(extension) {
|
|
let { promise } = this._menusManagers.get(extension) ?? {};
|
|
if (promise) {
|
|
return promise;
|
|
}
|
|
|
|
const instance = new ExtensionMenusManager(extension);
|
|
extension.callOnClose({
|
|
close: () => {
|
|
this._menusManagers.delete(extension);
|
|
},
|
|
});
|
|
promise = instance.asyncInit().then(() => instance);
|
|
this._menusManagers.set(extension, { promise, instance });
|
|
return promise;
|
|
},
|
|
|
|
_getManager(extension) {
|
|
const { instance } = this._menusManagers.get(extension) ?? {};
|
|
if (!instance) {
|
|
throw new Error(
|
|
`No ExtensionMenusManager instance found for ${extension.id}`
|
|
);
|
|
}
|
|
return instance;
|
|
},
|
|
|
|
/**
|
|
* Helper function used to normalize and merge menus
|
|
* create and update properties.
|
|
*
|
|
* @param {object} obj The target object
|
|
* @param {object} properties The properties to merge
|
|
* on the target object.
|
|
*
|
|
* @returns {object} The target object updated with
|
|
* the merged properties.
|
|
*/
|
|
mergeMenuProperties(obj, properties) {
|
|
// The menu properties are being normalized based on
|
|
// the API JSONSchema definitions, and so we can
|
|
// rely on expecting properties not specified to be
|
|
// set to null, besides "icons" which is expected to
|
|
// be omitted when not explicitly specified (due to
|
|
// the use of `"optional": "omit-key-if-missing"` in
|
|
// its schema definition).
|
|
for (let propName in properties) {
|
|
if (properties[propName] === null) {
|
|
// Omitted optional argument.
|
|
continue;
|
|
}
|
|
obj[propName] = properties[propName];
|
|
}
|
|
|
|
if ("icons" in properties && properties.icons === null && obj.icons) {
|
|
obj.icons = null;
|
|
}
|
|
|
|
return obj;
|
|
},
|
|
|
|
/**
|
|
* Synchronously retrieve the map of the extension menus.
|
|
* Expected to only be called after ext-menus onStartup lifecycle
|
|
* method has already initialized the ExtensionMenusManager through
|
|
* a call to ExtensionMenus.asyncInitForExtension.
|
|
*
|
|
* @returns {Map<MenuId, object>} Map of the menus createProperties keyed by
|
|
* menu id.
|
|
*/
|
|
getMenus(extension) {
|
|
return this._getManager(extension).getMenus();
|
|
},
|
|
|
|
_getStoredMenusForTesting(extensionId, extensionVersion) {
|
|
return store.getPersistedMenus(extensionId, extensionVersion);
|
|
},
|
|
|
|
_hasStoredExtensionData(extensionId) {
|
|
return store.hasExtensionData(extensionId);
|
|
},
|
|
|
|
_getStoreForTesting() {
|
|
return store;
|
|
},
|
|
|
|
_recreateStoreForTesting() {
|
|
store = new ExtensionMenusStore();
|
|
return store;
|
|
},
|
|
|
|
/**
|
|
* Add a new extension menu for the given extension. A DeferredTask
|
|
* will update all menus data stored on disk for extensions that should
|
|
* persist menus across sessions.
|
|
*
|
|
* Used by menus.create API method.
|
|
*
|
|
* @param {Extension} extension
|
|
* @param {object} createProperties The properties for the
|
|
* newly created menu.
|
|
*/
|
|
addMenu(extension, createProperties) {
|
|
// Only keep properties that are necessary.
|
|
const menuProperties = this.mergeMenuProperties({}, createProperties);
|
|
return this._getManager(extension).updateMenus(menuProperties);
|
|
},
|
|
|
|
/**
|
|
* Update menu data and optionally reparent menus (used when the update to
|
|
* a menu includes a different parentId). A DeferredTask scheduled by this
|
|
* method will update all menus data stored on disk for extensions that should
|
|
* persist menus across sessions.
|
|
*
|
|
* Used by menus.update API method.
|
|
*
|
|
* @param {Extension} extension
|
|
* @param {MenuId} menuId The id of the menu to be updated.
|
|
* @param {object} updateProperties The properties to update on an existing
|
|
* menu.
|
|
* be reparented.
|
|
*/
|
|
updateMenu(extension, menuId, updateProperties) {
|
|
let menuProperties = this.getMenus(extension).get(menuId);
|
|
let needsReparenting =
|
|
updateProperties.parentId != null &&
|
|
menuProperties.parentId != updateProperties.parentId;
|
|
// Only keep properties that are necessary.
|
|
menuProperties = this.mergeMenuProperties(
|
|
this.getMenus(extension).get(menuId),
|
|
updateProperties
|
|
);
|
|
return this._getManager(extension).updateMenus(
|
|
menuProperties,
|
|
needsReparenting
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Delete the given menu ids. A DeferredTask will update all menus
|
|
* data stored on disk for extensions that should persist menus across sessions.
|
|
*
|
|
* @param {Extension} extension
|
|
* @param {Array<MenuId>} menuIds Array of menu ids to remove.
|
|
*/
|
|
deleteMenus(extension, menuIds) {
|
|
this._getManager(extension).deleteMenus(menuIds);
|
|
},
|
|
|
|
/**
|
|
* Delete all menus. A DeferredTask scheduled by this method will update all menus
|
|
* data stored on disk for extensions that should persist menus across sessions.
|
|
*
|
|
* @param {Extension} extension
|
|
*/
|
|
deleteAllMenus(extension) {
|
|
this._getManager(extension).deleteAllMenus();
|
|
},
|
|
|
|
/**
|
|
* Remove the entry for the given extensionId from the data stored on disk (if any).
|
|
*
|
|
* @param {string} extensionId
|
|
*
|
|
* @returns {Promise<void>} A promise resolved when the extension data has been
|
|
* removed from the store.
|
|
*/
|
|
clearPersistedMenusOnUninstall(extensionId) {
|
|
return store.clearPersistedMenusOnUninstall(extensionId);
|
|
},
|
|
};
|