Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  ExtensionMenus.sys.mjs   Sprache: unbekannt

 
/* -*- 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/. */

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);
  },
};

[ Dauer der Verarbeitung: 0.4 Sekunden  (vorverarbeitet)  ]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge