content-process-storage.js (14711B)
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 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 9 const lazy = {}; 10 ChromeUtils.defineESModuleGetters( 11 lazy, 12 { 13 getAddonIdForWindowGlobal: 14 "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs", 15 }, 16 { global: "contextual" } 17 ); 18 19 // ms of delay to throttle updates 20 const BATCH_DELAY = 200; 21 22 // Filters "stores-update" response to only include events for 23 // the storage type we desire 24 function getFilteredStorageEvents(updates, storageType) { 25 const filteredUpdate = Object.create(null); 26 27 // updateType will be "added", "changed", or "deleted" 28 for (const updateType in updates) { 29 if (updates[updateType][storageType]) { 30 if (!filteredUpdate[updateType]) { 31 filteredUpdate[updateType] = {}; 32 } 33 filteredUpdate[updateType][storageType] = 34 updates[updateType][storageType]; 35 } 36 } 37 38 return Object.keys(filteredUpdate).length ? filteredUpdate : null; 39 } 40 41 class ContentProcessStorage { 42 constructor(ActorConstructor, storageKey) { 43 this.ActorConstructor = ActorConstructor; 44 this.storageKey = storageKey; 45 46 this.onStoresUpdate = this.onStoresUpdate.bind(this); 47 this.onStoresCleared = this.onStoresCleared.bind(this); 48 } 49 50 async watch(targetActor, { onAvailable }) { 51 const storageActor = new StorageActorMock(targetActor); 52 this.storageActor = storageActor; 53 this.actor = new this.ActorConstructor(storageActor); 54 55 // Some storage types require to prelist their stores 56 await this.actor.populateStoresForHosts(); 57 58 // We have to manage the actor manually, because ResourceCommand doesn't 59 // use the protocol.js specification. 60 // resources-available-array is typed as "json" 61 // So that we have to manually handle stuff that would normally be 62 // automagically done by procotol.js 63 // 1) Manage the actor in order to have an actorID on it 64 targetActor.manage(this.actor); 65 // 2) Convert to JSON "form" 66 const form = this.actor.form(); 67 68 // NOTE: this is hoisted, so the `update` method above may use it. 69 const storage = form; 70 71 // All resources should have a resourceId and resourceKey 72 // attributes, so available/updated/destroyed callbacks work properly. 73 storage.resourceId = this.storageKey; 74 storage.resourceKey = this.storageKey; 75 76 onAvailable([storage]); 77 78 // Maps global events from `storageActor` shared for all storage-types, 79 // down to storage-type's specific actor `storage`. 80 storageActor.on("stores-update", this.onStoresUpdate); 81 82 // When a store gets cleared 83 storageActor.on("stores-cleared", this.onStoresCleared); 84 } 85 86 onStoresUpdate(response) { 87 response = getFilteredStorageEvents(response, this.storageKey); 88 if (!response) { 89 return; 90 } 91 this.actor.emit("single-store-update", { 92 changed: response.changed, 93 added: response.added, 94 deleted: response.deleted, 95 }); 96 } 97 98 onStoresCleared(response) { 99 const cleared = response[this.storageKey]; 100 101 if (!cleared) { 102 return; 103 } 104 105 this.actor.emit("single-store-cleared", { 106 clearedHostsOrPaths: cleared, 107 }); 108 } 109 110 destroy() { 111 this.actor?.destroy(); 112 this.actor = null; 113 if (this.storageActor) { 114 this.storageActor.on("stores-update", this.onStoresUpdate); 115 this.storageActor.on("stores-cleared", this.onStoresCleared); 116 this.storageActor.destroy(); 117 this.storageActor = null; 118 } 119 } 120 } 121 122 module.exports = ContentProcessStorage; 123 124 // This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor 125 // But without being a protocol.js actor, nor implement any RDP method/event. 126 // An instance of this class is passed to each storage type actor and named `storageActor`. 127 // Once we implement all storage type in watcher classes, we can get rid of the original 128 // StorageActor in devtools/server/actors/storage.js 129 class StorageActorMock extends EventEmitter { 130 constructor(targetActor) { 131 super(); 132 // Storage classes fetch conn from storageActor 133 this.conn = targetActor.conn; 134 this.targetActor = targetActor; 135 136 this.childWindowPool = new Set(); 137 138 // Fetch all the inner iframe windows in this tab. 139 this.fetchChildWindows(this.targetActor.docShell); 140 141 // Notifications that help us keep track of newly added windows and windows 142 // that got removed 143 Services.obs.addObserver(this, "content-document-global-created"); 144 Services.obs.addObserver(this, "inner-window-destroyed"); 145 this.onPageChange = this.onPageChange.bind(this); 146 147 const handler = targetActor.chromeEventHandler; 148 handler.addEventListener("pageshow", this.onPageChange, true); 149 handler.addEventListener("pagehide", this.onPageChange, true); 150 151 this.destroyed = false; 152 this.boundUpdate = {}; 153 } 154 155 destroy() { 156 clearTimeout(this.batchTimer); 157 this.batchTimer = null; 158 // Remove observers 159 Services.obs.removeObserver(this, "content-document-global-created"); 160 Services.obs.removeObserver(this, "inner-window-destroyed"); 161 this.destroyed = true; 162 if (this.targetActor.browser) { 163 this.targetActor.browser.removeEventListener( 164 "pageshow", 165 this.onPageChange, 166 true 167 ); 168 this.targetActor.browser.removeEventListener( 169 "pagehide", 170 this.onPageChange, 171 true 172 ); 173 } 174 this.childWindowPool.clear(); 175 176 this.childWindowPool = null; 177 this.targetActor = null; 178 this.boundUpdate = null; 179 } 180 181 get window() { 182 return this.targetActor.window; 183 } 184 185 get document() { 186 return this.targetActor.window.document; 187 } 188 189 get windows() { 190 return this.childWindowPool; 191 } 192 193 /** 194 * Given a docshell, recursively find out all the child windows from it. 195 * 196 * @param {nsIDocShell} item 197 * The docshell from which all inner windows need to be extracted. 198 */ 199 fetchChildWindows(item) { 200 const docShell = item 201 .QueryInterface(Ci.nsIDocShell) 202 .QueryInterface(Ci.nsIDocShellTreeItem); 203 if (!docShell.docViewer) { 204 return null; 205 } 206 const window = docShell.docViewer.DOMDocument.defaultView; 207 if (window.location.href == "about:blank") { 208 // Skip out about:blank windows as Gecko creates them multiple times while 209 // creating any global. 210 return null; 211 } 212 if (!this.isIncludedInTopLevelWindow(window)) { 213 return null; 214 } 215 this.childWindowPool.add(window); 216 for (let i = 0; i < docShell.childCount; i++) { 217 const child = docShell.getChildAt(i); 218 this.fetchChildWindows(child); 219 } 220 return null; 221 } 222 223 isIncludedInTargetExtension(subject) { 224 const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild); 225 return addonId && addonId === this.targetActor.addonId; 226 } 227 228 isIncludedInTopLevelWindow(window) { 229 return this.targetActor.windows.includes(window); 230 } 231 232 getWindowFromInnerWindowID(innerID) { 233 innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data; 234 for (const win of this.childWindowPool.values()) { 235 const id = win.windowGlobalChild.innerWindowId; 236 if (id == innerID) { 237 return win; 238 } 239 } 240 return null; 241 } 242 243 getWindowFromHost(host) { 244 for (const win of this.childWindowPool.values()) { 245 const origin = win.document.nodePrincipal.originNoSuffix; 246 const url = win.document.URL; 247 if (origin === host || url === host) { 248 return win; 249 } 250 } 251 return null; 252 } 253 254 /** 255 * Event handler for any docshell update. This lets us figure out whenever 256 * any new window is added, or an existing window is removed. 257 */ 258 observe(subject, topic) { 259 if ( 260 subject.location && 261 (!subject.location.href || subject.location.href == "about:blank") 262 ) { 263 return null; 264 } 265 266 // We don't want to try to find a top level window for an extension page, as 267 // in many cases (e.g. background page), it is not loaded in a tab, and 268 // 'isIncludedInTopLevelWindow' throws an error 269 if ( 270 topic == "content-document-global-created" && 271 (this.isIncludedInTargetExtension(subject) || 272 this.isIncludedInTopLevelWindow(subject)) 273 ) { 274 this.childWindowPool.add(subject); 275 this.emit("window-ready", subject); 276 } else if (topic == "inner-window-destroyed") { 277 const window = this.getWindowFromInnerWindowID(subject); 278 if (window) { 279 this.childWindowPool.delete(window); 280 this.emit("window-destroyed", window); 281 } 282 } 283 return null; 284 } 285 286 /** 287 * Called on "pageshow" or "pagehide" event on the chromeEventHandler of 288 * current tab. 289 * 290 * @param {event} The event object passed to the handler. We are using these 291 * three properties from the event: 292 * - target {document} The document corresponding to the event. 293 * - type {string} Name of the event - "pageshow" or "pagehide". 294 * - persisted {boolean} true if there was no 295 * "content-document-global-created" notification along 296 * this event. 297 */ 298 onPageChange({ target, type, persisted }) { 299 if (this.destroyed) { 300 return; 301 } 302 303 const window = target.defaultView; 304 305 if (type == "pagehide" && this.childWindowPool.delete(window)) { 306 this.emit("window-destroyed", window); 307 } else if ( 308 type == "pageshow" && 309 persisted && 310 window.location.href && 311 window.location.href != "about:blank" && 312 this.isIncludedInTopLevelWindow(window) 313 ) { 314 this.childWindowPool.add(window); 315 this.emit("window-ready", window); 316 } 317 } 318 319 /** 320 * This method is called by the registered storage types so as to tell the 321 * Storage Actor that there are some changes in the stores. Storage Actor then 322 * notifies the client front about these changes at regular (BATCH_DELAY) 323 * interval. 324 * 325 * @param {string} action 326 * The type of change. One of "added", "changed" or "deleted" 327 * @param {string} storeType 328 * The storage actor in which this change has occurred. 329 * @param {object} data 330 * The update object. This object is of the following format: 331 * - { 332 * <host1>: [<store_names1>, <store_name2>...], 333 * <host2>: [<store_names34>...], 334 * } 335 * Where host1, host2 are the host in which this change happened and 336 * [<store_namesX] is an array of the names of the changed store objects. 337 * Pass an empty array if the host itself was affected: either completely 338 * removed or cleared. 339 */ 340 // eslint-disable-next-line complexity 341 update(action, storeType, data) { 342 if (action == "cleared") { 343 this.emit("stores-cleared", { [storeType]: data }); 344 return null; 345 } 346 347 if (this.batchTimer) { 348 clearTimeout(this.batchTimer); 349 } 350 if (!this.boundUpdate[action]) { 351 this.boundUpdate[action] = {}; 352 } 353 if (!this.boundUpdate[action][storeType]) { 354 this.boundUpdate[action][storeType] = {}; 355 } 356 for (const host in data) { 357 if (!this.boundUpdate[action][storeType][host]) { 358 this.boundUpdate[action][storeType][host] = []; 359 } 360 for (const name of data[host]) { 361 if (!this.boundUpdate[action][storeType][host].includes(name)) { 362 this.boundUpdate[action][storeType][host].push(name); 363 } 364 } 365 } 366 if (action == "added") { 367 // If the same store name was previously deleted or changed, but now is 368 // added somehow, dont send the deleted or changed update. 369 this.removeNamesFromUpdateList("deleted", storeType, data); 370 this.removeNamesFromUpdateList("changed", storeType, data); 371 } else if ( 372 action == "changed" && 373 this.boundUpdate.added && 374 this.boundUpdate.added[storeType] 375 ) { 376 // If something got added and changed at the same time, then remove those 377 // items from changed instead. 378 this.removeNamesFromUpdateList( 379 "changed", 380 storeType, 381 this.boundUpdate.added[storeType] 382 ); 383 } else if (action == "deleted") { 384 // If any item got delete, or a host got delete, no point in sending 385 // added or changed update 386 this.removeNamesFromUpdateList("added", storeType, data); 387 this.removeNamesFromUpdateList("changed", storeType, data); 388 389 for (const host in data) { 390 if ( 391 !data[host].length && 392 this.boundUpdate.added && 393 this.boundUpdate.added[storeType] && 394 this.boundUpdate.added[storeType][host] 395 ) { 396 delete this.boundUpdate.added[storeType][host]; 397 } 398 if ( 399 !data[host].length && 400 this.boundUpdate.changed && 401 this.boundUpdate.changed[storeType] && 402 this.boundUpdate.changed[storeType][host] 403 ) { 404 delete this.boundUpdate.changed[storeType][host]; 405 } 406 } 407 } 408 409 this.batchTimer = setTimeout(() => { 410 clearTimeout(this.batchTimer); 411 this.emit("stores-update", this.boundUpdate); 412 this.boundUpdate = {}; 413 }, BATCH_DELAY); 414 415 return null; 416 } 417 418 /** 419 * This method removes data from the this.boundUpdate object in the same 420 * manner like this.update() adds data to it. 421 * 422 * @param {string} action 423 * The type of change. One of "added", "changed" or "deleted" 424 * @param {string} storeType 425 * The storage actor for which you want to remove the updates data. 426 * @param {object} data 427 * The update object. This object is of the following format: 428 * - { 429 * <host1>: [<store_names1>, <store_name2>...], 430 * <host2>: [<store_names34>...], 431 * } 432 * Where host1, host2 are the hosts which you want to remove and 433 * [<store_namesX] is an array of the names of the store objects. 434 */ 435 removeNamesFromUpdateList(action, storeType, data) { 436 for (const host in data) { 437 if ( 438 this.boundUpdate[action] && 439 this.boundUpdate[action][storeType] && 440 this.boundUpdate[action][storeType][host] 441 ) { 442 for (const name in data[host]) { 443 const index = this.boundUpdate[action][storeType][host].indexOf(name); 444 if (index > -1) { 445 this.boundUpdate[action][storeType][host].splice(index, 1); 446 } 447 } 448 if (!this.boundUpdate[action][storeType][host].length) { 449 delete this.boundUpdate[action][storeType][host]; 450 } 451 } 452 } 453 return null; 454 } 455 }