Source code
Revision control
Copy as Markdown
Other Tools
/* -*- 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
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
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",
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"MENU_STORE_WRITE_DEBOUNCE_TIME",
"extensions.webextensions.menus.writeDebounceTime",
// TODO: agree on the default value to set on this pref.
5000, // Default to 5s
null,
// Minimum 0ms, max 1min
value => Math.min(Math.max(value, 0), 1 * 60 * 1000)
);
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);
},
};