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 }