addonsreconciler.sys.mjs (17498B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * This file contains middleware to reconcile state of AddonManager for 7 * purposes of tracking events for Sync. The content in this file exists 8 * because AddonManager does not have a getChangesSinceX() API and adding 9 * that functionality properly was deemed too time-consuming at the time 10 * add-on sync was originally written. If/when AddonManager adds this API, 11 * this file can go away and the add-ons engine can be rewritten to use it. 12 * 13 * It was decided to have this tracking functionality exist in a separate 14 * standalone file so it could be more easily understood, tested, and 15 * hopefully ported. 16 */ 17 18 import { Log } from "resource://gre/modules/Log.sys.mjs"; 19 20 import { Svc, Utils } from "resource://services-sync/util.sys.mjs"; 21 22 import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs"; 23 24 const DEFAULT_STATE_FILE = "addonsreconciler"; 25 26 export var CHANGE_INSTALLED = 1; 27 export var CHANGE_UNINSTALLED = 2; 28 export var CHANGE_ENABLED = 3; 29 export var CHANGE_DISABLED = 4; 30 31 /** 32 * Maintains state of add-ons. 33 * 34 * State is maintained in 2 data structures, an object mapping add-on IDs 35 * to metadata and an array of changes over time. The object mapping can be 36 * thought of as a minimal copy of data from AddonManager which is needed for 37 * Sync. The array is effectively a log of changes over time. 38 * 39 * The data structures are persisted to disk by serializing to a JSON file in 40 * the current profile. The data structures are updated by 2 mechanisms. First, 41 * they can be refreshed from the global state of the AddonManager. This is a 42 * sure-fire way of ensuring the reconciler is up to date. Second, the 43 * reconciler adds itself as an AddonManager listener. When it receives change 44 * notifications, it updates its internal state incrementally. 45 * 46 * The internal state is persisted to a JSON file in the profile directory. 47 * 48 * An instance of this is bound to an AddonsEngine instance. In reality, it 49 * likely exists as a singleton. To AddonsEngine, it functions as a store and 50 * an entity which emits events for tracking. 51 * 52 * The usage pattern for instances of this class is: 53 * 54 * let reconciler = new AddonsReconciler(...); 55 * await reconciler.ensureStateLoaded(); 56 * 57 * // At this point, your instance should be ready to use. 58 * 59 * When you are finished with the instance, please call: 60 * 61 * reconciler.stopListening(); 62 * await reconciler.saveState(...); 63 * 64 * This class uses the AddonManager AddonListener interface. 65 * When an add-on is installed, listeners are called in the following order: 66 * AL.onInstalling, AL.onInstalled 67 * 68 * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. 69 * 70 * Enabling and disabling work by sending: 71 * 72 * AL.onEnabling, AL.onEnabled 73 * AL.onDisabling, AL.onDisabled 74 * 75 * Actions can be undone. All undoable actions notify the same 76 * AL.onOperationCancelled event. We treat this event like any other. 77 * 78 * When an add-on is uninstalled from about:addons, the user is offered an 79 * "Undo" option, which leads to the following sequence of events as 80 * observed by an AddonListener: 81 * Add-ons are first disabled then they are actually uninstalled. So, we will 82 * see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled 83 * events only come after the Addon Manager is closed or another view is 84 * switched to. In the case of Sync performing the uninstall, the uninstall 85 * events will occur immediately. However, we still see disabling events and 86 * heed them like they were normal. In the end, the state is proper. 87 */ 88 export function AddonsReconciler(queueCaller) { 89 this._log = Log.repository.getLogger("Sync.AddonsReconciler"); 90 this._log.manageLevelFromPref("services.sync.log.logger.addonsreconciler"); 91 this.queueCaller = queueCaller; 92 93 Svc.Obs.add("xpcom-shutdown", this.stopListening, this); 94 } 95 96 AddonsReconciler.prototype = { 97 /** Flag indicating whether we are listening to AddonManager events. */ 98 _listening: false, 99 100 /** 101 * Define this as false if the reconciler should not persist state 102 * to disk when handling events. 103 * 104 * This allows test code to avoid spinning to write during observer 105 * notifications and xpcom shutdown, which appears to cause hangs on WinXP 106 * (Bug 873861). 107 */ 108 _shouldPersist: true, 109 110 /** Log logger instance */ 111 _log: null, 112 113 /** 114 * Container for add-on metadata. 115 * 116 * Keys are add-on IDs. Values are objects which describe the state of the 117 * add-on. This is a minimal mirror of data that can be queried from 118 * AddonManager. In some cases, we retain data longer than AddonManager. 119 */ 120 _addons: {}, 121 122 /** 123 * List of add-on changes over time. 124 * 125 * Each element is an array of [time, change, id]. 126 */ 127 _changes: [], 128 129 /** 130 * Objects subscribed to changes made to this instance. 131 */ 132 _listeners: [], 133 134 /** 135 * Accessor for add-ons in this object. 136 * 137 * Returns an object mapping add-on IDs to objects containing metadata. 138 */ 139 get addons() { 140 return this._addons; 141 }, 142 143 async ensureStateLoaded() { 144 if (!this._promiseStateLoaded) { 145 this._promiseStateLoaded = this.loadState(); 146 } 147 return this._promiseStateLoaded; 148 }, 149 150 /** 151 * Load reconciler state from a file. 152 * 153 * The path is relative to the weave directory in the profile. If no 154 * path is given, the default one is used. 155 * 156 * If the file does not exist or there was an error parsing the file, the 157 * state will be transparently defined as empty. 158 * 159 * @param file 160 * Path to load. ".json" is appended automatically. If not defined, 161 * a default path will be consulted. 162 */ 163 async loadState(file = DEFAULT_STATE_FILE) { 164 let json = await Utils.jsonLoad(file, this); 165 this._addons = {}; 166 this._changes = []; 167 168 if (!json) { 169 this._log.debug("No data seen in loaded file: " + file); 170 return false; 171 } 172 173 let version = json.version; 174 if (!version || version != 1) { 175 this._log.error( 176 "Could not load JSON file because version not " + 177 "supported: " + 178 version 179 ); 180 return false; 181 } 182 183 this._addons = json.addons; 184 for (let id in this._addons) { 185 let record = this._addons[id]; 186 record.modified = new Date(record.modified); 187 } 188 189 for (let [time, change, id] of json.changes) { 190 this._changes.push([new Date(time), change, id]); 191 } 192 193 return true; 194 }, 195 196 /** 197 * Saves the current state to a file in the local profile. 198 * 199 * @param file 200 * String path in profile to save to. If not defined, the default 201 * will be used. 202 */ 203 async saveState(file = DEFAULT_STATE_FILE) { 204 let state = { version: 1, addons: {}, changes: [] }; 205 206 for (let [id, record] of Object.entries(this._addons)) { 207 state.addons[id] = {}; 208 for (let [k, v] of Object.entries(record)) { 209 if (k == "modified") { 210 state.addons[id][k] = v.getTime(); 211 } else { 212 state.addons[id][k] = v; 213 } 214 } 215 } 216 217 for (let [time, change, id] of this._changes) { 218 state.changes.push([time.getTime(), change, id]); 219 } 220 221 this._log.info("Saving reconciler state to file: " + file); 222 await Utils.jsonSave(file, this, state); 223 }, 224 225 /** 226 * Registers a change listener with this instance. 227 * 228 * Change listeners are called every time a change is recorded. The listener 229 * is an object with the function "changeListener" that takes 3 arguments, 230 * the Date at which the change happened, the type of change (a CHANGE_* 231 * constant), and the add-on state object reflecting the current state of 232 * the add-on at the time of the change. 233 * 234 * @param listener 235 * Object containing changeListener function. 236 */ 237 addChangeListener: function addChangeListener(listener) { 238 if (!this._listeners.includes(listener)) { 239 this._log.debug("Adding change listener."); 240 this._listeners.push(listener); 241 } 242 }, 243 244 /** 245 * Removes a previously-installed change listener from the instance. 246 * 247 * @param listener 248 * Listener instance to remove. 249 */ 250 removeChangeListener: function removeChangeListener(listener) { 251 this._listeners = this._listeners.filter(element => { 252 if (element == listener) { 253 this._log.debug("Removing change listener."); 254 return false; 255 } 256 return true; 257 }); 258 }, 259 260 /** 261 * Tells the instance to start listening for AddonManager changes. 262 * 263 * This is typically called automatically when Sync is loaded. 264 */ 265 startListening: function startListening() { 266 if (this._listening) { 267 return; 268 } 269 270 this._log.info("Registering as Add-on Manager listener."); 271 AddonManager.addAddonListener(this); 272 this._listening = true; 273 }, 274 275 /** 276 * Tells the instance to stop listening for AddonManager changes. 277 * 278 * The reconciler should always be listening. This should only be called when 279 * the instance is being destroyed. 280 * 281 * This function will get called automatically on XPCOM shutdown. However, it 282 * is a best practice to call it yourself. 283 */ 284 stopListening: function stopListening() { 285 if (!this._listening) { 286 return; 287 } 288 289 this._log.debug("Stopping listening and removing AddonManager listener."); 290 AddonManager.removeAddonListener(this); 291 this._listening = false; 292 }, 293 294 /** 295 * Refreshes the global state of add-ons by querying the AddonManager. 296 */ 297 async refreshGlobalState() { 298 this._log.info("Refreshing global state from AddonManager."); 299 300 let installs; 301 let addons = await AddonManager.getAllAddons(); 302 303 let ids = {}; 304 305 for (let addon of addons) { 306 ids[addon.id] = true; 307 await this.rectifyStateFromAddon(addon); 308 } 309 310 // Look for locally-defined add-ons that no longer exist and update their 311 // record. 312 for (let [id, addon] of Object.entries(this._addons)) { 313 if (id in ids) { 314 continue; 315 } 316 317 // If the id isn't in ids, it means that the add-on has been deleted or 318 // the add-on is in the process of being installed. We detect the 319 // latter by seeing if an AddonInstall is found for this add-on. 320 321 if (!installs) { 322 installs = await AddonManager.getAllInstalls(); 323 } 324 325 let installFound = false; 326 for (let install of installs) { 327 if ( 328 install.addon && 329 install.addon.id == id && 330 install.state == AddonManager.STATE_INSTALLED 331 ) { 332 installFound = true; 333 break; 334 } 335 } 336 337 if (installFound) { 338 continue; 339 } 340 341 if (addon.installed) { 342 addon.installed = false; 343 this._log.debug( 344 "Adding change because add-on not present in " + 345 "Add-on Manager: " + 346 id 347 ); 348 await this._addChange(new Date(), CHANGE_UNINSTALLED, addon); 349 } 350 } 351 352 // See note for _shouldPersist. 353 if (this._shouldPersist) { 354 await this.saveState(); 355 } 356 }, 357 358 /** 359 * Rectifies the state of an add-on from an Addon instance. 360 * 361 * This basically says "given an Addon instance, assume it is truth and 362 * apply changes to the local state to reflect it." 363 * 364 * This function could result in change listeners being called if the local 365 * state differs from the passed add-on's state. 366 * 367 * @param addon 368 * Addon instance being updated. 369 */ 370 async rectifyStateFromAddon(addon) { 371 this._log.debug( 372 `Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})` 373 ); 374 375 let id = addon.id; 376 let enabled = !addon.userDisabled; 377 let guid = addon.syncGUID; 378 let now = new Date(); 379 380 if (!(id in this._addons)) { 381 let record = { 382 id, 383 guid, 384 enabled, 385 installed: true, 386 modified: now, 387 type: addon.type, 388 scope: addon.scope, 389 foreignInstall: addon.foreignInstall, 390 isSyncable: addon.isSyncable, 391 }; 392 this._addons[id] = record; 393 this._log.debug( 394 "Adding change because add-on not present locally: " + id 395 ); 396 await this._addChange(now, CHANGE_INSTALLED, record); 397 return; 398 } 399 400 let record = this._addons[id]; 401 record.isSyncable = addon.isSyncable; 402 403 if (!record.installed) { 404 // It is possible the record is marked as uninstalled because an 405 // uninstall is pending. 406 if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) { 407 record.installed = true; 408 record.modified = now; 409 } 410 } 411 412 if (record.enabled != enabled) { 413 record.enabled = enabled; 414 record.modified = now; 415 let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED; 416 this._log.debug("Adding change because enabled state changed: " + id); 417 await this._addChange(new Date(), change, record); 418 } 419 420 if (record.guid != guid) { 421 record.guid = guid; 422 // We don't record a change because the Sync engine rectifies this on its 423 // own. This is tightly coupled with Sync. If this code is ever lifted 424 // outside of Sync, this exception should likely be removed. 425 } 426 }, 427 428 /** 429 * Record a change in add-on state. 430 * 431 * @param date 432 * Date at which the change occurred. 433 * @param change 434 * The type of the change. A CHANGE_* constant. 435 * @param state 436 * The new state of the add-on. From this.addons. 437 */ 438 async _addChange(date, change, state) { 439 this._log.info("Change recorded for " + state.id); 440 this._changes.push([date, change, state.id]); 441 442 for (let listener of this._listeners) { 443 try { 444 await listener.changeListener(date, change, state); 445 } catch (ex) { 446 this._log.error("Exception calling change listener", ex); 447 } 448 } 449 }, 450 451 /** 452 * Obtain the set of changes to add-ons since the date passed. 453 * 454 * This will return an array of arrays. Each entry in the array has the 455 * elements [date, change_type, id], where 456 * 457 * date - Date instance representing when the change occurred. 458 * change_type - One of CHANGE_* constants. 459 * id - ID of add-on that changed. 460 */ 461 getChangesSinceDate(date) { 462 let length = this._changes.length; 463 for (let i = 0; i < length; i++) { 464 if (this._changes[i][0] >= date) { 465 return this._changes.slice(i); 466 } 467 } 468 469 return []; 470 }, 471 472 /** 473 * Prunes all recorded changes from before the specified Date. 474 * 475 * @param date 476 * Entries older than this Date will be removed. 477 */ 478 pruneChangesBeforeDate(date) { 479 this._changes = this._changes.filter(function test_age(change) { 480 return change[0] >= date; 481 }); 482 }, 483 484 /** 485 * Obtains the set of all known Sync GUIDs for add-ons. 486 */ 487 getAllSyncGUIDs() { 488 let result = {}; 489 for (let id in this.addons) { 490 result[id] = true; 491 } 492 493 return result; 494 }, 495 496 /** 497 * Obtain the add-on state record for an add-on by Sync GUID. 498 * 499 * If the add-on could not be found, returns null. 500 * 501 * @param guid 502 * Sync GUID of add-on to retrieve. 503 */ 504 getAddonStateFromSyncGUID(guid) { 505 for (let id in this.addons) { 506 let addon = this.addons[id]; 507 if (addon.guid == guid) { 508 return addon; 509 } 510 } 511 512 return null; 513 }, 514 515 /** 516 * Handler that is invoked as part of the AddonManager listeners. 517 */ 518 async _handleListener(action, addon) { 519 // Since this is called as an observer, we explicitly trap errors and 520 // log them to ourselves so we don't see errors reported elsewhere. 521 try { 522 let id = addon.id; 523 this._log.debug("Add-on change: " + action + " to " + id); 524 525 switch (action) { 526 case "onEnabled": 527 case "onDisabled": 528 case "onInstalled": 529 case "onInstallEnded": 530 case "onOperationCancelled": 531 await this.rectifyStateFromAddon(addon); 532 break; 533 534 case "onUninstalled": { 535 let id = addon.id; 536 let addons = this.addons; 537 if (id in addons) { 538 let now = new Date(); 539 let record = addons[id]; 540 record.installed = false; 541 record.modified = now; 542 this._log.debug( 543 "Adding change because of uninstall listener: " + id 544 ); 545 await this._addChange(now, CHANGE_UNINSTALLED, record); 546 } 547 } 548 } 549 550 // See note for _shouldPersist. 551 if (this._shouldPersist) { 552 await this.saveState(); 553 } 554 } catch (ex) { 555 this._log.warn("Exception", ex); 556 } 557 }, 558 559 // AddonListeners 560 onEnabled: function onEnabled(addon) { 561 this.queueCaller.enqueueCall(() => 562 this._handleListener("onEnabled", addon) 563 ); 564 }, 565 onDisabled: function onDisabled(addon) { 566 this.queueCaller.enqueueCall(() => 567 this._handleListener("onDisabled", addon) 568 ); 569 }, 570 onInstalled: function onInstalled(addon) { 571 this.queueCaller.enqueueCall(() => 572 this._handleListener("onInstalled", addon) 573 ); 574 }, 575 onUninstalled: function onUninstalled(addon) { 576 this.queueCaller.enqueueCall(() => 577 this._handleListener("onUninstalled", addon) 578 ); 579 }, 580 onOperationCancelled: function onOperationCancelled(addon) { 581 this.queueCaller.enqueueCall(() => 582 this._handleListener("onOperationCancelled", addon) 583 ); 584 }, 585 };