/* vim: se cin sw=2 ts=2 et filetype=javascript : * 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/. */ const kStorageVersion = 1; let lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { return console.createInstance({ prefix: "TaskbarTabs", maxLogLevel: "Warn", }); }); /** * Returns a JSON schema validator for Taskbar Tabs persistent storage. * * @returns {Promise} Resolves to JSON schema validator for Taskbar Tab's persistent storage. */ async function getJsonSchema() { const kJsonSchema = "chrome://browser/content/taskbartabs/TaskbarTabs.1.schema.json"; let res = await fetch(kJsonSchema); let obj = await res.json(); return new lazy.JsonSchema.Validator(obj); } /** * Storage class for a single Taskbar Tab's persistent storage. */ class TaskbarTab { // Unique identifier for the Taskbar Tab. #id; // List of scopes associated with this Taskbar Tab. A scope has a 'hostname' // property, and a 'prefix' property. If a 'prefix' is set, then the path // must literally start with that prefix; this matches the 'within scope' // algorithm of the Web App Manifest specification. // // @type {{ hostname: string; [prefix]: string }[]} #scopes = []; // Container the Taskbar Tab is opened in when opened from the Taskbar. #userContextId; // URL opened when a Taskbar Tab is opened from the Taskbar. #startUrl; // Human-readable name of this Taskbar Tab. #name; // The path to the shortcut associated with this Taskbar Tab, *relative // to the `Start Menu\Programs` folder.* #shortcutRelativePath; constructor({ id, scopes, startUrl, name, userContextId, shortcutRelativePath, }) { this.#id = id; this.#scopes = scopes; this.#userContextId = userContextId; this.#startUrl = startUrl; this.#name = name; this.#shortcutRelativePath = shortcutRelativePath ?? null; } get id() { return this.#id; } get scopes() { return [...this.#scopes]; } get userContextId() { return this.#userContextId; } get startUrl() { return this.#startUrl; } get name() { return this.#name; } get shortcutRelativePath() { return this.#shortcutRelativePath; } /** * Whether the provided URL is navigable from the Taskbar Tab. * * @param {nsIURI} aUrl - The URL to navigate to. * @returns {boolean} `true` if the URL is navigable from the Taskbar Tab associated to the ID. * @throws {Error} If `aId` is not a valid Taskbar Tabs ID. */ isScopeNavigable(aUrl) { let baseDomain = Services.eTLD.getBaseDomain(aUrl); for (const scope of this.#scopes) { let scopeBaseDomain = Services.eTLD.getBaseDomainFromHost(scope.hostname); // Domains in the same base domain are valid navigation targets. if (baseDomain === scopeBaseDomain) { lazy.logConsole.info(`${aUrl} is navigable for scope ${scope}.`); return true; } } lazy.logConsole.info( `${aUrl} is not navigable for Taskbar Tab ID ${this.#id}.` ); return false; } toJSON() { const maybe = (self, name) => (self[name] ? { [name]: self[name] } : {}); return { id: this.id, scopes: this.scopes, userContextId: this.userContextId, startUrl: this.startUrl, name: this.name, ...maybe(this, "shortcutRelativePath"), }; } /** * Applies mutable fields from aPatch to this object. * * Always use TaskbarTabsRegistry.patchTaskbarTab instead. Aside * from calling into this, it notifies other objects (especially * the saver) about the change. * * @param {object} aPatch - An object with properties to change. */ _applyPatch(aPatch) { if ("shortcutRelativePath" in aPatch) { this.#shortcutRelativePath = aPatch.shortcutRelativePath; } } } export const kTaskbarTabsRegistryEvents = Object.freeze({ created: "created", patched: "patched", removed: "removed", }); /** * Storage class for Taskbar Tabs feature's persistent storage. */ export class TaskbarTabsRegistry { // List of registered Taskbar Tabs. #taskbarTabs = []; // Signals when Taskbar Tabs have been created or removed. #emitter = new lazy.EventEmitter(); static get events() { return kTaskbarTabsRegistryEvents; } /** * Initializes a Taskbar Tabs Registry, optionally loading from a file. * * @param {object} [init] - Initialization context. * @param {nsIFile} [init.loadFile] - Optional file to load. */ static async create({ loadFile } = {}) { let registry = new TaskbarTabsRegistry(); if (loadFile) { await registry.#load(loadFile); } return registry; } /** * Loads the stored Taskbar Tabs. * * @param {nsIFile} aFile - File to load from. */ async #load(aFile) { if (!aFile.exists()) { lazy.logConsole.error(`File ${aFile.path} does not exist.`); return; } lazy.logConsole.info(`Loading file ${aFile.path} for Taskbar Tabs.`); const [schema, jsonObject] = await Promise.all([ getJsonSchema(), IOUtils.readJSON(aFile.path), ]); if (!schema.validate(jsonObject).valid) { throw new Error( `JSON from file ${aFile.path} is invalid for the Taskbar Tabs Schema.` ); } if (jsonObject.version > kStorageVersion) { throw new Error(`File ${aFile.path} has an unrecognized version. Current Version: ${kStorageVersion} File Version: ${jsonObject.version}`); } this.#taskbarTabs = jsonObject.taskbarTabs.map( tt => new TaskbarTab(migrateStoredTaskbarTab(tt)) ); } toJSON() { return { version: kStorageVersion, taskbarTabs: this.#taskbarTabs.map(tt => { return tt.toJSON(); }), }; } /** * Finds or creates a Taskbar Tab based on the provided URL and container. * * @param {nsIURI} aUrl - The URL to match or derive the scope and start URL from. * @param {number} aUserContextId - The container to start a Taskbar Tab in. * @param {object} aDetails - Additional options to use if it needs to be * created. * @param {object} aDetails.manifest - The Web app manifest that should be * associated with this Taskbar Tab. * @returns {{taskbarTab:TaskbarTab, created:bool}} * The matching or created Taskbar Tab, along with whether it was created. */ findOrCreateTaskbarTab(aUrl, aUserContextId, { manifest = {} } = {}) { let existing = this.findTaskbarTab(aUrl, aUserContextId); if (existing) { return { created: false, taskbarTab: existing, }; } let scope = { hostname: aUrl.host }; if ("scope" in manifest) { // Note: manifest.scope will not be set unless the start_url is // within scope. As such, this scope always contains the start_url. // If a manifest is used but there isn't a scope, it uses the parent // of the start_url; e.g. '/a/b/c.html' --> '/a/b'. const scopeUri = Services.io.newURI(manifest.scope); scope = { hostname: scopeUri.host, prefix: scopeUri.filePath, }; } let id = Services.uuid.generateUUID().toString().slice(1, -1); let taskbarTab = new TaskbarTab({ id, scopes: [scope], userContextId: aUserContextId, name: manifest.name ?? generateName(aUrl), startUrl: manifest.start_url ?? aUrl.prePath, }); this.#taskbarTabs.push(taskbarTab); lazy.logConsole.info(`Created Taskbar Tab with ID ${id}`); Glean.webApp.install.record({}); this.#emitter.emit(kTaskbarTabsRegistryEvents.created, taskbarTab); return { created: true, taskbarTab, }; } /** * Removes a Taskbar Tab. * * @param {string} aId - The ID of the TaskbarTab to remove. * @returns {TaskbarTab?} The removed taskbar tab, or null if it wasn't * found. */ removeTaskbarTab(aId) { let tts = this.#taskbarTabs; const i = tts.findIndex(tt => { return tt.id === aId; }); if (i > -1) { lazy.logConsole.info(`Removing Taskbar Tab Id ${tts[i].id}`); let removed = tts.splice(i, 1); Glean.webApp.uninstall.record({}); this.#emitter.emit(kTaskbarTabsRegistryEvents.removed, removed[0]); return removed[0]; } lazy.logConsole.error(`Taskbar Tab ID ${aId} not found.`); return null; } /** * Searches for an existing Taskbar Tab matching the URL and Container. * * @param {nsIURL} aUrl - The URL to match. * @param {number} aUserContextId - The container to match. * @returns {TaskbarTab|null} The matching Taskbar Tab, or null if none match. */ findTaskbarTab(aUrl, aUserContextId) { // Ensure that the caller uses the correct types. nsIURI alone isn't // enough---we need to know that there's a hostname and that the structure // is otherwise standard. if (!(aUrl instanceof Ci.nsIURL)) { throw new TypeError( "Invalid argument, `aUrl` should be instance of `nsIURL`" ); } if (typeof aUserContextId !== "number") { throw new TypeError( "Invalid argument, `aUserContextId` should be type of `number`" ); } for (const tt of this.#taskbarTabs) { let bestPrefix = ""; for (const scope of tt.scopes) { if (aUrl.host !== scope.hostname) { continue; } if ("prefix" in scope) { if (scope.prefix.length < bestPrefix.length) { // We've already found something better. continue; } if (!aUrl.filePath.startsWith(scope.prefix)) { // This URL wouldn't be within scope. continue; } } if (aUserContextId !== tt.userContextId) { lazy.logConsole.info( `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname}, but container ${aUserContextId} mismatched ${tt.userContextId}.` ); } else { lazy.logConsole.info( `Matched TaskbarTab for URL ${aUrl.host} to ${scope.hostname} with container ${aUserContextId}.` ); return tt; } } } lazy.logConsole.info( `No matching TaskbarTab found for URL ${aUrl.spec} and container ${aUserContextId}.` ); return null; } /** * Retrieves the Taskbar Tab matching the ID. * * @param {string} aId - The ID of the Taskbar Tab. * @returns {TaskbarTab} The matching Taskbar Tab. * @throws {Error} If `aId` is not a valid Taskbar Tab ID. */ getTaskbarTab(aId) { const tt = this.#taskbarTabs.find(aTaskbarTab => { return aTaskbarTab.id === aId; }); if (!tt) { lazy.logConsole.error(`Taskbar Tab Id ${aId} not found.`); throw new Error(`Taskbar Tab Id ${aId} is invalid.`); } return tt; } /** * Updates properties within the provided Taskbar Tab. * * All fields from aPatch will be assigned to aTaskbarTab, except * for the ID. * * @param {TaskbarTab} aTaskbarTab - The taskbar tab to update. * @param {object} aPatch - An object with properties to change. * @throws {Error} If any taskbar tab in aTaskbarTabs is unknown. */ patchTaskbarTab(aTaskbarTab, aPatch) { // This is done from the registry to make it more clear that an event // will fire, and thus that I/O might be possible. aTaskbarTab._applyPatch(aPatch); this.#emitter.emit(kTaskbarTabsRegistryEvents.patched, aTaskbarTab); } /** * Gets the number of taskbar tabs that are registered in this registry. * * @returns {number} The number of registered taskbar tabs. */ countTaskbarTabs() { return this.#taskbarTabs.length; } /** * Passthrough to `EventEmitter.on`. * * @param {...any} args - Same as `EventEmitter.on`. */ on(...args) { return this.#emitter.on(...args); } /** * Passthrough to `EventEmitter.off` * * @param {...any} args - Same as `EventEmitter.off` */ off(...args) { return this.#emitter.off(...args); } /** * Resets the in-memory Taskbar Tabs state for tests. */ resetForTests() { this.#taskbarTabs = []; } } /** * Monitor for the Taskbar Tabs Registry that updates the save file as it * changes. * * Note: this intentionally does not save on schema updates to allow for * gracefall rollback to an earlier version of Firefox where possible. This is * desirable in cases where a user has unintentioally opened a profile on a * newer version of Firefox, or has reverted an update. */ export class TaskbarTabsRegistryStorage { // The registry to save. #registry; // The file saved to. #saveFile; // Promise queue to ensure that async writes don't occur out of order. #saveQueue = Promise.resolve(); /** * @param {TaskbarTabsRegistry} aRegistry - The registry to serialize. * @param {nsIFile} aSaveFile - The save file to update. */ constructor(aRegistry, aSaveFile) { this.#registry = aRegistry; this.#saveFile = aSaveFile; } /** * Serializes the Taskbar Tabs Registry into a JSON file. * * Note: file writes are strictly ordered, ensuring the sequence of serialized * object writes reflects the latest state even if any individual write * serializes the registry in a newer state than when it's associated event * was emitted. * * @returns {Promise} Resolves once the current save operation completes. */ save() { this.#saveQueue = this.#saveQueue .finally(async () => { lazy.logConsole.info(`Updating Taskbar Tabs storage file.`); const schema = await getJsonSchema(); // Copy the JSON object to prevent awaits after validation risking // TOCTOU if the registry changes.. let json = this.#registry.toJSON(); let result = schema.validate(json); if (!result.valid) { throw new Error( "Generated invalid JSON for the Taskbar Tabs Schema:\n" + JSON.stringify(result.errors) ); } await IOUtils.makeDirectory(this.#saveFile.parent.path); await IOUtils.writeJSON(this.#saveFile.path, json); lazy.logConsole.info(`Tasbkar Tabs storage file updated.`); }) .catch(e => { lazy.logConsole.error(`Error writing Taskbar Tabs file: ${e}`); }); lazy.AsyncShutdown.profileBeforeChange.addBlocker( "Taskbar Tabs: finalizing registry serialization to disk.", this.#saveQueue ); return this.#saveQueue; } } /** * Mutates the provided Taskbar Tab object from storage so it contains all * current properties. * * @param {object} aStored - The object stored in the database; this will be * mutated as part of migrating it. * @returns {object} aStored exactly. */ function migrateStoredTaskbarTab(aStored) { if (typeof aStored.name !== "string") { try { aStored.name = generateName(Services.io.newURI(aStored.startUrl)); } catch (e) { lazy.logConsole.warn(`Migrating ${aStored.id} failed:`, e); } } return aStored; } /** * Generates a name for the Taskbar Tab appropriate for user facing UI. * * @param {nsIURI} aUri - The URI to derive the name from. * @returns {string} A name suitable for user facing UI. */ function generateName(aUri) { // https://www.subdomain.example.co.uk/test // ["www", "subdomain", "example", "co", "uk"] let hostParts = aUri.host.split("."); // ["subdomain", "example", "co", "uk"] if (hostParts[0] === "www") { hostParts.shift(); } let suffixDomainCount = Services.eTLD .getKnownPublicSuffix(aUri) .split(".").length; // ["subdomain", "example"] hostParts.splice(-suffixDomainCount); let name = hostParts // ["example", "subdomain"] .reverse() // ["Example", "Subdomain"] .map(s => s.charAt(0).toUpperCase() + s.slice(1)) // "Example Subdomain" .join(" "); return name; }