tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TaskbarTabsRegistry.sys.mjs (16309B)


      1 /* vim: se cin sw=2 ts=2 et filetype=javascript :
      2 * This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      5 
      6 const kStorageVersion = 1;
      7 
      8 let lazy = {};
      9 
     10 ChromeUtils.defineESModuleGetters(lazy, {
     11  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
     12  EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
     13  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
     14 });
     15 
     16 ChromeUtils.defineLazyGetter(lazy, "logConsole", () => {
     17  return console.createInstance({
     18    prefix: "TaskbarTabs",
     19    maxLogLevel: "Warn",
     20  });
     21 });
     22 
     23 /**
     24 * Returns a JSON schema validator for Taskbar Tabs persistent storage.
     25 *
     26 * @returns {Promise<Validator>} Resolves to JSON schema validator for Taskbar Tab's persistent storage.
     27 */
     28 async function getJsonSchema() {
     29  const kJsonSchema =
     30    "chrome://browser/content/taskbartabs/TaskbarTabs.1.schema.json";
     31  let res = await fetch(kJsonSchema);
     32  let obj = await res.json();
     33  return new lazy.JsonSchema.Validator(obj);
     34 }
     35 
     36 /**
     37 * Storage class for a single Taskbar Tab's persistent storage.
     38 */
     39 class TaskbarTab {
     40  // Unique identifier for the Taskbar Tab.
     41  #id;
     42  // List of scopes associated with this Taskbar Tab. A scope has a 'hostname'
     43  // property, and a 'prefix' property. If a 'prefix' is set, then the path
     44  // must literally start with that prefix; this matches the 'within scope'
     45  // algorithm of the Web App Manifest specification.
     46  //
     47  // @type {{ hostname: string; [prefix]: string }[]}
     48  #scopes = [];
     49  // Container the Taskbar Tab is opened in when opened from the Taskbar.
     50  #userContextId;
     51  // URL opened when a Taskbar Tab is opened from the Taskbar.
     52  #startUrl;
     53  // Human-readable name of this Taskbar Tab.
     54  #name;
     55  // The path to the shortcut associated with this Taskbar Tab, *relative
     56  // to the `Start Menu\Programs` folder.*
     57  #shortcutRelativePath;
     58 
     59  constructor({
     60    id,
     61    scopes,
     62    startUrl,
     63    name,
     64    userContextId,
     65    shortcutRelativePath,
     66  }) {
     67    this.#id = id;
     68    this.#scopes = scopes;
     69    this.#userContextId = userContextId;
     70    this.#startUrl = startUrl;
     71    this.#name = name;
     72 
     73    this.#shortcutRelativePath = shortcutRelativePath ?? null;
     74  }
     75 
     76  get id() {
     77    return this.#id;
     78  }
     79 
     80  get scopes() {
     81    return [...this.#scopes];
     82  }
     83 
     84  get userContextId() {
     85    return this.#userContextId;
     86  }
     87 
     88  get startUrl() {
     89    return this.#startUrl;
     90  }
     91 
     92  get name() {
     93    return this.#name;
     94  }
     95 
     96  get shortcutRelativePath() {
     97    return this.#shortcutRelativePath;
     98  }
     99 
    100  /**
    101   * Whether the provided URL is navigable from the Taskbar Tab.
    102   *
    103   * @param {nsIURI} aUrl - The URL to navigate to.
    104   * @returns {boolean} `true` if the URL is navigable from the Taskbar Tab associated to the ID.
    105   * @throws {Error} If `aId` is not a valid Taskbar Tabs ID.
    106   */
    107  isScopeNavigable(aUrl) {
    108    let baseDomain = Services.eTLD.getBaseDomain(aUrl);
    109 
    110    for (const scope of this.#scopes) {
    111      let scopeBaseDomain = Services.eTLD.getBaseDomainFromHost(scope.hostname);
    112 
    113      // Domains in the same base domain are valid navigation targets.
    114      if (baseDomain === scopeBaseDomain) {
    115        lazy.logConsole.info(`${aUrl} is navigable for scope ${scope}.`);
    116        return true;
    117      }
    118    }
    119 
    120    lazy.logConsole.info(
    121      `${aUrl} is not navigable for Taskbar Tab ID ${this.#id}.`
    122    );
    123    return false;
    124  }
    125 
    126  toJSON() {
    127    const maybe = (self, name) => (self[name] ? { [name]: self[name] } : {});
    128    return {
    129      id: this.id,
    130      scopes: this.scopes,
    131      userContextId: this.userContextId,
    132      startUrl: this.startUrl,
    133      name: this.name,
    134      ...maybe(this, "shortcutRelativePath"),
    135    };
    136  }
    137 
    138  /**
    139   * Applies mutable fields from aPatch to this object.
    140   *
    141   * Always use TaskbarTabsRegistry.patchTaskbarTab instead. Aside
    142   * from calling into this, it notifies other objects (especially
    143   * the saver) about the change.
    144   *
    145   * @param {object} aPatch - An object with properties to change.
    146   */
    147  _applyPatch(aPatch) {
    148    if ("shortcutRelativePath" in aPatch) {
    149      this.#shortcutRelativePath = aPatch.shortcutRelativePath;
    150    }
    151  }
    152 }
    153 
    154 export const kTaskbarTabsRegistryEvents = Object.freeze({
    155  created: "created",
    156  patched: "patched",
    157  removed: "removed",
    158 });
    159 
    160 /**
    161 * Storage class for Taskbar Tabs feature's persistent storage.
    162 */
    163 export class TaskbarTabsRegistry {
    164  // List of registered Taskbar Tabs.
    165  #taskbarTabs = [];
    166  // Signals when Taskbar Tabs have been created or removed.
    167  #emitter = new lazy.EventEmitter();
    168 
    169  static get events() {
    170    return kTaskbarTabsRegistryEvents;
    171  }
    172 
    173  /**
    174   * Initializes a Taskbar Tabs Registry, optionally loading from a file.
    175   *
    176   * @param {object} [init] - Initialization context.
    177   * @param {nsIFile} [init.loadFile] - Optional file to load.
    178   */
    179  static async create({ loadFile } = {}) {
    180    let registry = new TaskbarTabsRegistry();
    181    if (loadFile) {
    182      await registry.#load(loadFile);
    183    }
    184 
    185    return registry;
    186  }
    187 
    188  /**
    189   * Loads the stored Taskbar Tabs.
    190   *
    191   * @param {nsIFile} aFile - File to load from.
    192   */
    193  async #load(aFile) {
    194    if (!aFile.exists()) {
    195      lazy.logConsole.error(`File ${aFile.path} does not exist.`);
    196      return;
    197    }
    198 
    199    lazy.logConsole.info(`Loading file ${aFile.path} for Taskbar Tabs.`);
    200 
    201    const [schema, jsonObject] = await Promise.all([
    202      getJsonSchema(),
    203      IOUtils.readJSON(aFile.path),
    204    ]);
    205 
    206    if (!schema.validate(jsonObject).valid) {
    207      throw new Error(
    208        `JSON from file ${aFile.path} is invalid for the Taskbar Tabs Schema.`
    209      );
    210    }
    211    if (jsonObject.version > kStorageVersion) {
    212      throw new Error(`File ${aFile.path} has an unrecognized version.
    213          Current Version: ${kStorageVersion}
    214          File Version: ${jsonObject.version}`);
    215    }
    216    this.#taskbarTabs = jsonObject.taskbarTabs.map(
    217      tt => new TaskbarTab(migrateStoredTaskbarTab(tt))
    218    );
    219  }
    220 
    221  toJSON() {
    222    return {
    223      version: kStorageVersion,
    224      taskbarTabs: this.#taskbarTabs.map(tt => {
    225        return tt.toJSON();
    226      }),
    227    };
    228  }
    229 
    230  /**
    231   * Finds or creates a Taskbar Tab based on the provided URL and container.
    232   *
    233   * @param {nsIURI} aUrl - The URL to match or derive the scope and start URL from.
    234   * @param {number} aUserContextId - The container to start a Taskbar Tab in.
    235   * @param {object} aDetails - Additional options to use if it needs to be
    236   * created.
    237   * @param {object} aDetails.manifest - The Web app manifest that should be
    238   * associated with this Taskbar Tab.
    239   * @returns {{taskbarTab:TaskbarTab, created:bool}}
    240   *   The matching or created Taskbar Tab, along with whether it was created.
    241   */
    242  findOrCreateTaskbarTab(aUrl, aUserContextId, { manifest = {} } = {}) {
    243    let existing = this.findTaskbarTab(aUrl, aUserContextId);
    244    if (existing) {
    245      return {
    246        created: false,
    247        taskbarTab: existing,
    248      };
    249    }
    250 
    251    let scope = { hostname: aUrl.host };
    252    if ("scope" in manifest) {
    253      // Note: manifest.scope will not be set unless the start_url is
    254      // within scope. As such, this scope always contains the start_url.
    255      // If a manifest is used but there isn't a scope, it uses the parent
    256      // of the start_url; e.g. '/a/b/c.html' --> '/a/b'.
    257      const scopeUri = Services.io.newURI(manifest.scope);
    258      scope = {
    259        hostname: scopeUri.host,
    260        prefix: scopeUri.filePath,
    261      };
    262    }
    263 
    264    let id = Services.uuid.generateUUID().toString().slice(1, -1);
    265    let taskbarTab = new TaskbarTab({
    266      id,
    267      scopes: [scope],
    268      userContextId: aUserContextId,
    269      name: manifest.name ?? generateName(aUrl),
    270      startUrl: manifest.start_url ?? aUrl.prePath,
    271    });
    272    this.#taskbarTabs.push(taskbarTab);
    273 
    274    lazy.logConsole.info(`Created Taskbar Tab with ID ${id}`);
    275 
    276    Glean.webApp.install.record({});
    277    this.#emitter.emit(kTaskbarTabsRegistryEvents.created, taskbarTab);
    278 
    279    return {
    280      created: true,
    281      taskbarTab,
    282    };
    283  }
    284 
    285  /**
    286   * Removes a Taskbar Tab.
    287   *
    288   * @param {string} aId - The ID of the TaskbarTab to remove.
    289   * @returns {TaskbarTab?} The removed taskbar tab, or null if it wasn't
    290   * found.
    291   */
    292  removeTaskbarTab(aId) {
    293    let tts = this.#taskbarTabs;
    294    const i = tts.findIndex(tt => {
    295      return tt.id === aId;
    296    });
    297 
    298    if (i > -1) {
    299      lazy.logConsole.info(`Removing Taskbar Tab Id ${tts[i].id}`);
    300      let removed = tts.splice(i, 1);
    301 
    302      Glean.webApp.uninstall.record({});
    303      this.#emitter.emit(kTaskbarTabsRegistryEvents.removed, removed[0]);
    304      return removed[0];
    305    }
    306 
    307    lazy.logConsole.error(`Taskbar Tab ID ${aId} not found.`);
    308    return null;
    309  }
    310 
    311  /**
    312   * Searches for an existing Taskbar Tab matching the URL and Container.
    313   *
    314   * @param {nsIURL} aUrl - The URL to match.
    315   * @param {number} aUserContextId - The container to match.
    316   * @returns {TaskbarTab|null} The matching Taskbar Tab, or null if none match.
    317   */
    318  findTaskbarTab(aUrl, aUserContextId) {
    319    // Ensure that the caller uses the correct types. nsIURI alone isn't
    320    // enough---we need to know that there's a hostname and that the structure
    321    // is otherwise standard.
    322    if (!(aUrl instanceof Ci.nsIURL)) {
    323      throw new TypeError(
    324        "Invalid argument, `aUrl` should be instance of `nsIURL`"
    325      );
    326    }
    327    if (typeof aUserContextId !== "number") {
    328      throw new TypeError(
    329        "Invalid argument, `aUserContextId` should be type of `number`"
    330      );
    331    }
    332 
    333    for (const tt of this.#taskbarTabs) {
    334      let bestPrefix = "";
    335      for (const scope of tt.scopes) {
    336        if (aUrl.host !== scope.hostname) {
    337          continue;
    338        }
    339        if ("prefix" in scope) {
    340          if (scope.prefix.length < bestPrefix.length) {
    341            // We've already found something better.
    342            continue;
    343          }
    344          if (!aUrl.filePath.startsWith(scope.prefix)) {
    345            // This URL wouldn't be within scope.
    346            continue;
    347          }
    348        }
    349 
    350        if (aUserContextId !== tt.userContextId) {
    351          lazy.logConsole.info(
    352            `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname}, but container ${aUserContextId} mismatched ${tt.userContextId}.`
    353          );
    354        } else {
    355          lazy.logConsole.info(
    356            `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname} with container ${aUserContextId}.`
    357          );
    358          return tt;
    359        }
    360      }
    361    }
    362 
    363    lazy.logConsole.info(
    364      `No matching TaskbarTab found for URL ${aUrl.spec} and container ${aUserContextId}.`
    365    );
    366    return null;
    367  }
    368 
    369  /**
    370   * Retrieves the Taskbar Tab matching the ID.
    371   *
    372   * @param {string} aId - The ID of the Taskbar Tab.
    373   * @returns {TaskbarTab} The matching Taskbar Tab.
    374   * @throws {Error} If `aId` is not a valid Taskbar Tab ID.
    375   */
    376  getTaskbarTab(aId) {
    377    const tt = this.#taskbarTabs.find(aTaskbarTab => {
    378      return aTaskbarTab.id === aId;
    379    });
    380    if (!tt) {
    381      lazy.logConsole.error(`Taskbar Tab Id ${aId} not found.`);
    382      throw new Error(`Taskbar Tab Id ${aId} is invalid.`);
    383    }
    384 
    385    return tt;
    386  }
    387 
    388  /**
    389   * Updates properties within the provided Taskbar Tab.
    390   *
    391   * All fields from aPatch will be assigned to aTaskbarTab, except
    392   * for the ID.
    393   *
    394   * @param {TaskbarTab} aTaskbarTab - The taskbar tab to update.
    395   * @param {object} aPatch - An object with properties to change.
    396   * @throws {Error} If any taskbar tab in aTaskbarTabs is unknown.
    397   */
    398  patchTaskbarTab(aTaskbarTab, aPatch) {
    399    // This is done from the registry to make it more clear that an event
    400    // will fire, and thus that I/O might be possible.
    401    aTaskbarTab._applyPatch(aPatch);
    402    this.#emitter.emit(kTaskbarTabsRegistryEvents.patched, aTaskbarTab);
    403  }
    404 
    405  /**
    406   * Gets the number of taskbar tabs that are registered in this registry.
    407   *
    408   * @returns {number} The number of registered taskbar tabs.
    409   */
    410  countTaskbarTabs() {
    411    return this.#taskbarTabs.length;
    412  }
    413 
    414  /**
    415   * Passthrough to `EventEmitter.on`.
    416   *
    417   * @param  {...any} args - Same as `EventEmitter.on`.
    418   */
    419  on(...args) {
    420    return this.#emitter.on(...args);
    421  }
    422 
    423  /**
    424   * Passthrough to `EventEmitter.off`
    425   *
    426   * @param  {...any} args - Same as `EventEmitter.off`
    427   */
    428  off(...args) {
    429    return this.#emitter.off(...args);
    430  }
    431 
    432  /**
    433   * Resets the in-memory Taskbar Tabs state for tests.
    434   */
    435  resetForTests() {
    436    this.#taskbarTabs = [];
    437  }
    438 }
    439 
    440 /**
    441 * Monitor for the Taskbar Tabs Registry that updates the save file as it
    442 * changes.
    443 *
    444 * Note: this intentionally does not save on schema updates to allow for
    445 * gracefall rollback to an earlier version of Firefox where possible. This is
    446 * desirable in cases where a user has unintentioally opened a profile on a
    447 * newer version of Firefox, or has reverted an update.
    448 */
    449 export class TaskbarTabsRegistryStorage {
    450  // The registry to save.
    451  #registry;
    452  // The file saved to.
    453  #saveFile;
    454  // Promise queue to ensure that async writes don't occur out of order.
    455  #saveQueue = Promise.resolve();
    456 
    457  /**
    458   * @param {TaskbarTabsRegistry} aRegistry - The registry to serialize.
    459   * @param {nsIFile} aSaveFile - The save file to update.
    460   */
    461  constructor(aRegistry, aSaveFile) {
    462    this.#registry = aRegistry;
    463    this.#saveFile = aSaveFile;
    464  }
    465 
    466  /**
    467   * Serializes the Taskbar Tabs Registry into a JSON file.
    468   *
    469   * Note: file writes are strictly ordered, ensuring the sequence of serialized
    470   * object writes reflects the latest state even if any individual write
    471   * serializes the registry in a newer state than when it's associated event
    472   * was emitted.
    473   *
    474   * @returns {Promise} Resolves once the current save operation completes.
    475   */
    476  save() {
    477    this.#saveQueue = this.#saveQueue
    478      .finally(async () => {
    479        lazy.logConsole.info(`Updating Taskbar Tabs storage file.`);
    480 
    481        const schema = await getJsonSchema();
    482 
    483        // Copy the JSON object to prevent awaits after validation risking
    484        // TOCTOU if the registry changes..
    485        let json = this.#registry.toJSON();
    486 
    487        let result = schema.validate(json);
    488        if (!result.valid) {
    489          throw new Error(
    490            "Generated invalid JSON for the Taskbar Tabs Schema:\n" +
    491              JSON.stringify(result.errors)
    492          );
    493        }
    494 
    495        await IOUtils.makeDirectory(this.#saveFile.parent.path);
    496        await IOUtils.writeJSON(this.#saveFile.path, json);
    497 
    498        lazy.logConsole.info(`Tasbkar Tabs storage file updated.`);
    499      })
    500      .catch(e => {
    501        lazy.logConsole.error(`Error writing Taskbar Tabs file: ${e}`);
    502      });
    503 
    504    lazy.AsyncShutdown.profileBeforeChange.addBlocker(
    505      "Taskbar Tabs: finalizing registry serialization to disk.",
    506      this.#saveQueue
    507    );
    508 
    509    return this.#saveQueue;
    510  }
    511 }
    512 
    513 /**
    514 * Mutates the provided Taskbar Tab object from storage so it contains all
    515 * current properties.
    516 *
    517 * @param {object} aStored - The object stored in the database; this will be
    518 * mutated as part of migrating it.
    519 * @returns {object} aStored exactly.
    520 */
    521 function migrateStoredTaskbarTab(aStored) {
    522  if (typeof aStored.name !== "string") {
    523    try {
    524      aStored.name = generateName(Services.io.newURI(aStored.startUrl));
    525    } catch (e) {
    526      lazy.logConsole.warn(`Migrating ${aStored.id} failed:`, e);
    527    }
    528  }
    529 
    530  return aStored;
    531 }
    532 
    533 /**
    534 * Generates a name for the Taskbar Tab appropriate for user facing UI.
    535 *
    536 * @param {nsIURI} aUri - The URI to derive the name from.
    537 * @returns {string} A name suitable for user facing UI.
    538 */
    539 function generateName(aUri) {
    540  // https://www.subdomain.example.co.uk/test
    541 
    542  // ["www", "subdomain", "example", "co", "uk"]
    543  let hostParts = aUri.host.split(".");
    544 
    545  // ["subdomain", "example", "co", "uk"]
    546  if (hostParts[0] === "www") {
    547    hostParts.shift();
    548  }
    549 
    550  let suffixDomainCount = Services.eTLD
    551    .getKnownPublicSuffix(aUri)
    552    .split(".").length;
    553 
    554  // ["subdomain", "example"]
    555  hostParts.splice(-suffixDomainCount);
    556 
    557  let name = hostParts
    558    // ["example", "subdomain"]
    559    .reverse()
    560    // ["Example", "Subdomain"]
    561    .map(s => s.charAt(0).toUpperCase() + s.slice(1))
    562    // "Example Subdomain"
    563    .join(" ");
    564 
    565  return name;
    566 }