extension-storage.js (14155B)
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 { 8 BaseStorageActor, 9 } = require("resource://devtools/server/actors/resources/storage/index.js"); 10 const { 11 parseItemValue, 12 } = require("resource://devtools/shared/storage/utils.js"); 13 const { 14 LongStringActor, 15 } = require("resource://devtools/server/actors/string.js"); 16 // Use global: "shared" for these extension modules, because these 17 // are singletons with shared state, and we must not create a new instance if a 18 // dedicated loader was used to load this module. 19 loader.lazyGetter(this, "ExtensionParent", () => { 20 return ChromeUtils.importESModule( 21 "resource://gre/modules/ExtensionParent.sys.mjs", 22 { global: "shared" } 23 ).ExtensionParent; 24 }); 25 loader.lazyGetter(this, "ExtensionProcessScript", () => { 26 return ChromeUtils.importESModule( 27 "resource://gre/modules/ExtensionProcessScript.sys.mjs", 28 { global: "shared" } 29 ).ExtensionProcessScript; 30 }); 31 loader.lazyGetter(this, "ExtensionStorageIDB", () => { 32 return ChromeUtils.importESModule( 33 "resource://gre/modules/ExtensionStorageIDB.sys.mjs", 34 { global: "shared" } 35 ).ExtensionStorageIDB; 36 }); 37 38 /** 39 * The Extension Storage actor. 40 */ 41 class ExtensionStorageActor extends BaseStorageActor { 42 constructor(storageActor) { 43 super(storageActor, "extensionStorage"); 44 45 this.addonId = this.storageActor.parentActor.addonId; 46 47 // Retrieve the base moz-extension url for the extension 48 // (and also remove the final '/' from it). 49 this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1); 50 51 // Map<host, ExtensionStorageIDB db connection> 52 // Bug 1542038, 1542039: Each storage area will need its own 53 // dbConnectionForHost, as they each have different storage backends. 54 // Anywhere dbConnectionForHost is used, we need to know the storage 55 // area to access the correct database. 56 this.dbConnectionForHost = new Map(); 57 58 this.onExtensionStartup = this.onExtensionStartup.bind(this); 59 60 this.onStorageChange = this.onStorageChange.bind(this); 61 } 62 63 getExtensionPolicy() { 64 return WebExtensionPolicy.getByID(this.addonId); 65 } 66 67 destroy() { 68 ExtensionStorageIDB.removeOnChangedListener( 69 this.addonId, 70 this.onStorageChange 71 ); 72 ExtensionParent.apiManager.off("startup", this.onExtensionStartup); 73 74 super.destroy(); 75 } 76 77 /** 78 * We need to override this method as we ignore BaseStorageActor's hosts 79 * and only care about the extension host. 80 */ 81 async populateStoresForHosts() { 82 // Ensure the actor's target is an extension and it is enabled 83 if (!this.addonId || !this.getExtensionPolicy()) { 84 return; 85 } 86 87 // Subscribe a listener for event notifications from the WE storage API when 88 // storage local data has been changed by the extension, and keep track of the 89 // listener to remove it when the debugger is being disconnected. 90 ExtensionStorageIDB.addOnChangedListener( 91 this.addonId, 92 this.onStorageChange 93 ); 94 95 try { 96 // Make sure the extension storage APIs have been loaded, 97 // otherwise the DevTools storage panel would not be updated 98 // automatically when the extension storage data is being changed 99 // if the parent ext-storage.js module wasn't already loaded 100 // (See Bug 1802929). 101 const { extension } = WebExtensionPolicy.getByID(this.addonId); 102 await extension.apiManager.asyncGetAPI("storage", extension); 103 // Also watch for addon reload in order to also do that 104 // on next addon startup, otherwise we may also miss updates 105 ExtensionParent.apiManager.on("startup", this.onExtensionStartup); 106 } catch (e) { 107 console.error( 108 "Exception while trying to initialize webext storage API", 109 e 110 ); 111 } 112 113 await this.populateStoresForHost(this.extensionHostURL); 114 } 115 116 /** 117 * AddonManager listener used to force instantiating storage API 118 * implementation in the parent process so that it forward content process 119 * messages to ExtensionStorageIDB. 120 * 121 * Without this, we may miss storage updated after the addon reload. 122 */ 123 async onExtensionStartup(_evtName, extension) { 124 if (extension.id != this.addonId) { 125 return; 126 } 127 await extension.apiManager.asyncGetAPI("storage", extension); 128 } 129 130 /** 131 * This method asynchronously reads the storage data for the target extension 132 * and caches this data into this.hostVsStores. 133 * 134 * @param {string} host - the hostname for the extension 135 */ 136 async populateStoresForHost(host) { 137 if (host !== this.extensionHostURL) { 138 return; 139 } 140 141 const extension = ExtensionProcessScript.getExtensionChild(this.addonId); 142 if (!extension || !extension.hasPermission("storage")) { 143 return; 144 } 145 146 // Make sure storeMap is defined and set in this.hostVsStores before subscribing 147 // a storage onChanged listener in the parent process 148 const storeMap = new Map(); 149 this.hostVsStores.set(host, storeMap); 150 151 const storagePrincipal = await this.getStoragePrincipal(); 152 153 if (!storagePrincipal) { 154 // This could happen if the extension fails to be migrated to the 155 // IndexedDB backend 156 return; 157 } 158 159 const db = await ExtensionStorageIDB.open(storagePrincipal); 160 this.dbConnectionForHost.set(host, db); 161 const data = await db.get(); 162 163 for (const [key, value] of Object.entries(data)) { 164 storeMap.set(key, value); 165 } 166 } 167 /** 168 * This fires when the extension changes storage data while the storage 169 * inspector is open. Ensures this.hostVsStores stays up-to-date and 170 * passes the changes on to update the client. 171 */ 172 onStorageChange(changes) { 173 const host = this.extensionHostURL; 174 const storeMap = this.hostVsStores.get(host); 175 176 function isStructuredCloneHolder(value) { 177 return ( 178 value && 179 typeof value === "object" && 180 Cu.getClassName(value, true) === "StructuredCloneHolder" 181 ); 182 } 183 184 for (const key in changes) { 185 const storageChange = changes[key]; 186 let { newValue, oldValue } = storageChange; 187 if (isStructuredCloneHolder(newValue)) { 188 newValue = newValue.deserialize(this); 189 } 190 if (isStructuredCloneHolder(oldValue)) { 191 oldValue = oldValue.deserialize(this); 192 } 193 194 let action; 195 if (typeof newValue === "undefined") { 196 action = "deleted"; 197 storeMap.delete(key); 198 } else if (typeof oldValue === "undefined") { 199 action = "added"; 200 storeMap.set(key, newValue); 201 } else { 202 action = "changed"; 203 storeMap.set(key, newValue); 204 } 205 206 this.storageActor.update(action, this.typeName, { [host]: [key] }); 207 } 208 } 209 210 async getStoragePrincipal() { 211 const { extension } = this.getExtensionPolicy(); 212 const { backendEnabled, storagePrincipal } = 213 await ExtensionStorageIDB.selectBackend({ extension }); 214 215 if (!backendEnabled) { 216 // IDB backend disabled; give up. 217 return null; 218 } 219 220 // Received as a StructuredCloneHolder, so we need to deserialize 221 return storagePrincipal.deserialize(this, true); 222 } 223 224 getValuesForHost(host, name) { 225 const result = []; 226 227 if (!this.hostVsStores.has(host)) { 228 return result; 229 } 230 231 if (name) { 232 return [{ name, value: this.hostVsStores.get(host).get(name) }]; 233 } 234 235 for (const [key, value] of Array.from( 236 this.hostVsStores.get(host).entries() 237 )) { 238 result.push({ name: key, value }); 239 } 240 return result; 241 } 242 243 /** 244 * Converts a storage item to an "extensionobject" as defined in 245 * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor, 246 * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined` 247 * `item.value`). 248 * 249 * @param {object} item - The storage item to convert 250 * @param {string} item.name - The storage item key 251 * @param {*} item.value - The storage item value 252 * @return {extensionobject} 253 */ 254 toStoreObject(item) { 255 if (!item) { 256 return null; 257 } 258 259 let { name, value } = item; 260 const isValueEditable = extensionStorageHelpers.isEditable(value); 261 262 // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings, 263 // and doesn't modify `undefined`. 264 switch (typeof value) { 265 case "bigint": 266 value = `${value.toString()}n`; 267 break; 268 case "string": 269 break; 270 case "undefined": 271 value = "undefined"; 272 break; 273 default: 274 value = JSON.stringify(value); 275 if ( 276 // can't use `instanceof` across frame boundaries 277 Object.prototype.toString.call(item.value) === "[object Date]" 278 ) { 279 value = JSON.parse(value); 280 } 281 } 282 283 return { 284 name, 285 value: new LongStringActor(this.conn, value), 286 area: "local", // Bug 1542038, 1542039: set the correct storage area 287 isValueEditable, 288 }; 289 } 290 291 getFields() { 292 return [ 293 { name: "name", editable: false }, 294 { name: "value", editable: true }, 295 { name: "area", editable: false }, 296 { name: "isValueEditable", editable: false, private: true }, 297 ]; 298 } 299 300 onItemUpdated(action, host, names) { 301 this.storageActor.update(action, this.typeName, { 302 [host]: names, 303 }); 304 } 305 306 async editItem({ host, items }) { 307 const db = this.dbConnectionForHost.get(host); 308 if (!db) { 309 return; 310 } 311 312 const { name, value } = items; 313 314 let parsedValue = parseItemValue(value); 315 if (parsedValue === value) { 316 const { typesFromString } = extensionStorageHelpers; 317 for (const { test, parse } of Object.values(typesFromString)) { 318 if (test(value)) { 319 parsedValue = parse(value); 320 break; 321 } 322 } 323 } 324 const changes = await db.set({ [name]: parsedValue }); 325 this.fireOnChangedExtensionEvent(host, changes); 326 327 this.onItemUpdated("changed", host, [name]); 328 } 329 330 async removeItem(host, name) { 331 const db = this.dbConnectionForHost.get(host); 332 if (!db) { 333 return; 334 } 335 336 const changes = await db.remove(name); 337 this.fireOnChangedExtensionEvent(host, changes); 338 339 this.onItemUpdated("deleted", host, [name]); 340 } 341 342 async removeAll(host) { 343 const db = this.dbConnectionForHost.get(host); 344 if (!db) { 345 return; 346 } 347 348 const changes = await db.clear(); 349 this.fireOnChangedExtensionEvent(host, changes); 350 351 this.onItemUpdated("cleared", host, []); 352 } 353 354 /** 355 * Let the extension know that storage data has been changed by the user from 356 * the storage inspector. 357 */ 358 fireOnChangedExtensionEvent(host, changes) { 359 // Bug 1542038, 1542039: Which message to send depends on the storage area 360 const uuid = new URL(host).host; 361 Services.cpmm.sendAsyncMessage( 362 `Extension:StorageLocalOnChanged:${uuid}`, 363 changes 364 ); 365 } 366 } 367 exports.ExtensionStorageActor = ExtensionStorageActor; 368 369 const extensionStorageHelpers = { 370 /** 371 * Editing is supported only for serializable types. Examples of unserializable 372 * types include Map, Set and ArrayBuffer. 373 */ 374 isEditable(value) { 375 // Bug 1542038: the managed storage area is never editable 376 for (const { test } of Object.values(this.supportedTypes)) { 377 if (test(value)) { 378 return true; 379 } 380 } 381 return false; 382 }, 383 isPrimitive(value) { 384 const primitiveValueTypes = ["string", "number", "boolean"]; 385 return primitiveValueTypes.includes(typeof value) || value === null; 386 }, 387 isObjectLiteral(value) { 388 return ( 389 value && 390 typeof value === "object" && 391 Cu.getClassName(value, true) === "Object" 392 ); 393 }, 394 // Nested arrays or object literals are only editable 2 levels deep 395 isArrayOrObjectLiteralEditable(obj) { 396 const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj); 397 if ( 398 topLevelValuesArr.some( 399 value => 400 !this.isPrimitive(value) && 401 !Array.isArray(value) && 402 !this.isObjectLiteral(value) 403 ) 404 ) { 405 // At least one value is too complex to parse 406 return false; 407 } 408 const arrayOrObjects = topLevelValuesArr.filter( 409 value => Array.isArray(value) || this.isObjectLiteral(value) 410 ); 411 if (arrayOrObjects.length === 0) { 412 // All top level values are primitives 413 return true; 414 } 415 416 // One or more top level values was an array or object literal. 417 // All of these top level values must themselves have only primitive values 418 // for the object to be editable 419 for (const nestedObj of arrayOrObjects) { 420 const secondLevelValuesArr = Array.isArray(nestedObj) 421 ? nestedObj 422 : Object.values(nestedObj); 423 if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) { 424 return false; 425 } 426 } 427 return true; 428 }, 429 typesFromString: { 430 // Helper methods to parse string values in editItem 431 jsonifiable: { 432 test(str) { 433 try { 434 JSON.parse(str); 435 } catch (e) { 436 return false; 437 } 438 return true; 439 }, 440 parse(str) { 441 return JSON.parse(str); 442 }, 443 }, 444 }, 445 supportedTypes: { 446 // Helper methods to determine the value type of an item in isEditable 447 array: { 448 test(value) { 449 if (Array.isArray(value)) { 450 return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); 451 } 452 return false; 453 }, 454 }, 455 boolean: { 456 test(value) { 457 return typeof value === "boolean"; 458 }, 459 }, 460 null: { 461 test(value) { 462 return value === null; 463 }, 464 }, 465 number: { 466 test(value) { 467 return typeof value === "number"; 468 }, 469 }, 470 object: { 471 test(value) { 472 if (extensionStorageHelpers.isObjectLiteral(value)) { 473 return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value); 474 } 475 return false; 476 }, 477 }, 478 string: { 479 test(value) { 480 return typeof value === "string"; 481 }, 482 }, 483 }, 484 };