ui.js (56936B)
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 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 7 const { ELLIPSIS } = require("resource://devtools/shared/l10n.js"); 8 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); 9 const { 10 parseItemValue, 11 } = require("resource://devtools/shared/storage/utils.js"); 12 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 13 const { 14 getUnicodeHostname, 15 } = require("resource://devtools/client/shared/unicode-url.js"); 16 const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js"); 17 18 // GUID to be used as a separator in compound keys. This must match the same 19 // constant in devtools/server/actors/resources/storage/index.js, 20 // devtools/client/storage/test/head.js and 21 // devtools/server/tests/browser/head.js 22 const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; 23 24 loader.lazyRequireGetter( 25 this, 26 "TreeWidget", 27 "resource://devtools/client/shared/widgets/TreeWidget.js", 28 true 29 ); 30 loader.lazyRequireGetter( 31 this, 32 "TableWidget", 33 "resource://devtools/client/shared/widgets/TableWidget.js", 34 true 35 ); 36 loader.lazyRequireGetter( 37 this, 38 "debounce", 39 "resource://devtools/shared/debounce.js", 40 true 41 ); 42 loader.lazyGetter(this, "standardSessionString", () => { 43 const l10n = new Localization(["devtools/client/storage.ftl"], true); 44 return l10n.formatValueSync("storage-expires-session"); 45 }); 46 47 const lazy = {}; 48 ChromeUtils.defineESModuleGetters(lazy, { 49 VariablesView: "resource://devtools/client/storage/VariablesView.sys.mjs", 50 }); 51 52 const REASON = { 53 NEW_ROW: "new-row", 54 NEXT_50_ITEMS: "next-50-items", 55 POPULATE: "populate", 56 UPDATE: "update", 57 }; 58 59 // How long we wait to debounce resize events 60 const LAZY_RESIZE_INTERVAL_MS = 200; 61 62 // Maximum length of item name to show in context menu label - will be 63 // trimmed with ellipsis if it's longer. 64 const ITEM_NAME_MAX_LENGTH = 32; 65 66 const HEADERS_L10N_IDS = { 67 Cache: { 68 status: "storage-table-headers-cache-status", 69 }, 70 cookies: { 71 creationTime: "storage-table-headers-cookies-creation-time", 72 updateTime: "storage-table-headers-cookies-update-time", 73 expires: "storage-table-headers-cookies-expires", 74 lastAccessed: "storage-table-headers-cookies-last-accessed", 75 name: "storage-table-headers-cookies-name", 76 size: "storage-table-headers-cookies-size", 77 value: "storage-table-headers-cookies-value", 78 }, 79 extensionStorage: { 80 area: "storage-table-headers-extension-storage-area", 81 }, 82 }; 83 84 // We only localize certain table headers. The headers that we do not localize 85 // along with their label are stored in this dictionary for easy reference. 86 const HEADERS_NON_L10N_STRINGS = { 87 Cache: { 88 url: "URL", 89 }, 90 cookies: { 91 host: "Domain", 92 hostOnly: "HostOnly", 93 isHttpOnly: "HttpOnly", 94 isSecure: "Secure", 95 partitionKey: "Partition Key", 96 path: "Path", 97 sameSite: "SameSite", 98 uniqueKey: "Unique key", 99 }, 100 extensionStorage: { 101 name: "Key", 102 value: "Value", 103 }, 104 indexedDB: { 105 autoIncrement: "Auto Increment", 106 db: "Database Name", 107 indexes: "Indexes", 108 keyPath: "Key Path", 109 name: "Key", 110 objectStore: "Object Store Name", 111 objectStores: "Object Stores", 112 origin: "Origin", 113 storage: "Storage", 114 uniqueKey: "Unique key", 115 value: "Value", 116 version: "Version", 117 }, 118 localStorage: { 119 name: "Key", 120 value: "Value", 121 }, 122 sessionStorage: { 123 name: "Key", 124 value: "Value", 125 }, 126 }; 127 128 /** 129 * StorageUI is controls and builds the UI of the Storage Inspector. 130 * 131 * @param {Window} panelWin 132 * Window of the toolbox panel to populate UI in. 133 * @param {object} commands 134 * The commands object with all interfaces defined from devtools/shared/commands/ 135 */ 136 class StorageUI { 137 constructor(panelWin, toolbox, commands) { 138 EventEmitter.decorate(this); 139 this._window = panelWin; 140 this._panelDoc = panelWin.document; 141 this._toolbox = toolbox; 142 this._commands = commands; 143 this.sidebarToggledOpen = null; 144 this.shouldLoadMoreItems = true; 145 146 const treeNode = this._panelDoc.getElementById("storage-tree"); 147 this.tree = new TreeWidget(treeNode, { 148 defaultType: "dir", 149 contextMenuId: "storage-tree-popup", 150 }); 151 this.onHostSelect = this.onHostSelect.bind(this); 152 this.tree.on("select", this.onHostSelect); 153 154 const tableNode = this._panelDoc.getElementById("storage-table"); 155 this.table = new TableWidget(tableNode, { 156 emptyText: "storage-table-empty-text", 157 highlightUpdated: true, 158 cellContextMenuId: "storage-table-popup", 159 l10n: this._panelDoc.l10n, 160 }); 161 162 this.updateObjectSidebar = this.updateObjectSidebar.bind(this); 163 this.table.on(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar); 164 165 this.handleScrollEnd = this.loadMoreItems.bind(this); 166 this.table.on(TableWidget.EVENTS.SCROLL_END, this.handleScrollEnd); 167 168 this.editItem = this.editItem.bind(this); 169 this.table.on(TableWidget.EVENTS.CELL_EDIT, this.editItem); 170 171 this.sidebar = this._panelDoc.getElementById("storage-sidebar"); 172 173 // Set suggested sizes for the xul:splitter's, so that the sidebar doesn't take too much space 174 // in horizontal mode (width) and vertical (height). 175 this.sidebar.style.width = "300px"; 176 this.sidebar.style.height = "300px"; 177 178 this.view = new lazy.VariablesView(this.sidebar.firstChild, { 179 lazyEmpty: true, 180 // ms 181 lazyEmptyDelay: 10, 182 searchEnabled: true, 183 contextMenuId: "variable-view-popup", 184 }); 185 186 this.filterItems = this.filterItems.bind(this); 187 this.onPaneToggleButtonClicked = this.onPaneToggleButtonClicked.bind(this); 188 this.setupToolbar(); 189 190 this.handleKeypress = this.handleKeypress.bind(this); 191 this._panelDoc.addEventListener("keypress", this.handleKeypress); 192 193 this.onTreePopupShowing = this.onTreePopupShowing.bind(this); 194 this._treePopup = this._panelDoc.getElementById("storage-tree-popup"); 195 this._treePopup.addEventListener("popupshowing", this.onTreePopupShowing); 196 197 this.onTablePopupShowing = this.onTablePopupShowing.bind(this); 198 this._tablePopup = this._panelDoc.getElementById("storage-table-popup"); 199 this._tablePopup.addEventListener("popupshowing", this.onTablePopupShowing); 200 201 this.onVariableViewPopupShowing = 202 this.onVariableViewPopupShowing.bind(this); 203 this._variableViewPopup = this._panelDoc.getElementById( 204 "variable-view-popup" 205 ); 206 this._variableViewPopup.addEventListener( 207 "popupshowing", 208 this.onVariableViewPopupShowing 209 ); 210 211 this.onRefreshTable = this.onRefreshTable.bind(this); 212 this.onAddItem = this.onAddItem.bind(this); 213 this.onCopyItem = this.onCopyItem.bind(this); 214 this.onPanelWindowResize = debounce( 215 this.#onLazyPanelResize, 216 LAZY_RESIZE_INTERVAL_MS, 217 this 218 ); 219 this.onRemoveItem = this.onRemoveItem.bind(this); 220 this.onRemoveAllFrom = this.onRemoveAllFrom.bind(this); 221 this.onRemoveAll = this.onRemoveAll.bind(this); 222 this.onRemoveAllSessionCookies = this.onRemoveAllSessionCookies.bind(this); 223 this.onRemoveTreeItem = this.onRemoveTreeItem.bind(this); 224 225 this._refreshButton = this._panelDoc.getElementById("refresh-button"); 226 this._refreshButton.addEventListener("click", this.onRefreshTable); 227 228 this._addButton = this._panelDoc.getElementById("add-button"); 229 this._addButton.addEventListener("click", this.onAddItem); 230 231 this._window.addEventListener("resize", this.onPanelWindowResize, true); 232 233 this._variableViewPopupCopy = this._panelDoc.getElementById( 234 "variable-view-popup-copy" 235 ); 236 this._variableViewPopupCopy.addEventListener("command", this.onCopyItem); 237 238 this._tablePopupAddItem = this._panelDoc.getElementById( 239 "storage-table-popup-add" 240 ); 241 this._tablePopupAddItem.addEventListener("command", this.onAddItem); 242 243 this._tablePopupDelete = this._panelDoc.getElementById( 244 "storage-table-popup-delete" 245 ); 246 this._tablePopupDelete.addEventListener("command", this.onRemoveItem); 247 248 this._tablePopupDeleteAllFrom = this._panelDoc.getElementById( 249 "storage-table-popup-delete-all-from" 250 ); 251 this._tablePopupDeleteAllFrom.addEventListener( 252 "command", 253 this.onRemoveAllFrom 254 ); 255 256 this._tablePopupDeleteAll = this._panelDoc.getElementById( 257 "storage-table-popup-delete-all" 258 ); 259 this._tablePopupDeleteAll.addEventListener("command", this.onRemoveAll); 260 261 this._tablePopupDeleteAllSessionCookies = this._panelDoc.getElementById( 262 "storage-table-popup-delete-all-session-cookies" 263 ); 264 this._tablePopupDeleteAllSessionCookies.addEventListener( 265 "command", 266 this.onRemoveAllSessionCookies 267 ); 268 269 this._treePopupDeleteAll = this._panelDoc.getElementById( 270 "storage-tree-popup-delete-all" 271 ); 272 this._treePopupDeleteAll.addEventListener("command", this.onRemoveAll); 273 274 this._treePopupDeleteAllSessionCookies = this._panelDoc.getElementById( 275 "storage-tree-popup-delete-all-session-cookies" 276 ); 277 this._treePopupDeleteAllSessionCookies.addEventListener( 278 "command", 279 this.onRemoveAllSessionCookies 280 ); 281 282 this._treePopupDelete = this._panelDoc.getElementById( 283 "storage-tree-popup-delete" 284 ); 285 this._treePopupDelete.addEventListener("command", this.onRemoveTreeItem); 286 } 287 288 get currentTarget() { 289 return this._commands.targetCommand.targetFront; 290 } 291 292 async init() { 293 // This is a distionary of arrays, keyed by storage key 294 // - Keys are storage keys, available on each storage resource, via ${resource.resourceKey} 295 // and are typically "Cache", "cookies", "indexedDB", "localStorage", ... 296 // - Values are arrays of storage fronts. This isn't the deprecated global storage front (target.getFront(storage), only used by legacy listener), 297 // but rather the storage specific front, i.e. a storage resource. Storage resources are fronts. 298 this.storageResources = {}; 299 300 await this._initL10NStringsMap(); 301 302 // This can only be done after l10n strings were retrieved as we're using "storage-filter-key" 303 const shortcuts = new KeyShortcuts({ 304 window: this._panelDoc.defaultView, 305 }); 306 const key = this._l10nStrings.get("storage-filter-key"); 307 shortcuts.on(key, event => { 308 event.preventDefault(); 309 this.searchBox.focus(); 310 }); 311 312 this._onTargetAvailable = this._onTargetAvailable.bind(this); 313 this._onTargetDestroyed = this._onTargetDestroyed.bind(this); 314 await this._commands.targetCommand.watchTargets({ 315 types: [this._commands.targetCommand.TYPES.FRAME], 316 onAvailable: this._onTargetAvailable, 317 onDestroyed: this._onTargetDestroyed, 318 }); 319 320 this._onResourceListAvailable = this._onResourceListAvailable.bind(this); 321 322 const { resourceCommand } = this._commands; 323 324 this._listenedResourceTypes = [ 325 // The first item in this list will be the first selected storage item 326 // Tests assume Cookie -- moving cookie will break tests 327 resourceCommand.TYPES.COOKIE, 328 resourceCommand.TYPES.CACHE_STORAGE, 329 resourceCommand.TYPES.INDEXED_DB, 330 resourceCommand.TYPES.LOCAL_STORAGE, 331 resourceCommand.TYPES.SESSION_STORAGE, 332 ]; 333 // EXTENSION_STORAGE is only relevant when debugging web extensions 334 if (this._commands.descriptorFront.isWebExtensionDescriptor) { 335 this._listenedResourceTypes.push(resourceCommand.TYPES.EXTENSION_STORAGE); 336 } 337 await this._commands.resourceCommand.watchResources( 338 this._listenedResourceTypes, 339 { 340 onAvailable: this._onResourceListAvailable, 341 } 342 ); 343 } 344 345 async _initL10NStringsMap() { 346 const ids = [ 347 "storage-filter-key", 348 "storage-table-headers-cookies-name", 349 "storage-table-headers-cookies-value", 350 "storage-table-headers-cookies-expires", 351 "storage-table-headers-cookies-size", 352 "storage-table-headers-cookies-last-accessed", 353 "storage-table-headers-cookies-creation-time", 354 "storage-table-headers-cookies-update-time", 355 "storage-table-headers-cache-status", 356 "storage-table-headers-extension-storage-area", 357 "storage-tree-labels-cookies", 358 "storage-tree-labels-local-storage", 359 "storage-tree-labels-session-storage", 360 "storage-tree-labels-indexed-db", 361 "storage-tree-labels-cache", 362 "storage-tree-labels-extension-storage", 363 "storage-expires-session", 364 ]; 365 const results = await this._panelDoc.l10n.formatValues( 366 ids.map(s => ({ id: s })) 367 ); 368 369 this._l10nStrings = new Map(ids.map((id, i) => [id, results[i]])); 370 } 371 372 async _onResourceListAvailable(resources) { 373 for (const resource of resources) { 374 if (resource.isDestroyed()) { 375 continue; 376 } 377 const { resourceKey } = resource; 378 379 // NOTE: We might be getting more than 1 resource per storage type when 380 // we have remote frames in content process resources, so we need 381 // an array to store these. 382 if (!this.storageResources[resourceKey]) { 383 this.storageResources[resourceKey] = []; 384 } 385 this.storageResources[resourceKey].push(resource); 386 387 resource.on( 388 "single-store-update", 389 this._onStoreUpdate.bind(this, resource) 390 ); 391 resource.on( 392 "single-store-cleared", 393 this._onStoreCleared.bind(this, resource) 394 ); 395 } 396 397 try { 398 await this.populateStorageTree(); 399 } catch (e) { 400 if (!this._toolbox || this._toolbox._destroyer) { 401 // The toolbox is in the process of being destroyed... in this case throwing here 402 // is expected and normal so let's ignore the error. 403 return; 404 } 405 406 // The toolbox is open so the error is unexpected and real so let's log it. 407 console.error(e); 408 } 409 } 410 411 // We only need to listen to target destruction, but TargetCommand.watchTarget 412 // requires a target available function... 413 async _onTargetAvailable() {} 414 415 _onTargetDestroyed({ targetFront }) { 416 // Remove all storages related to this target 417 for (const type in this.storageResources) { 418 this.storageResources[type] = this.storageResources[type].filter( 419 storage => { 420 // Note that the storage front may already be destroyed, 421 // and have a null targetFront attribute. So also remove all already 422 // destroyed fronts. 423 return !storage.isDestroyed() && storage.targetFront != targetFront; 424 } 425 ); 426 } 427 428 // Only support top level target and navigation to new processes. 429 // i.e. ignore additional targets created for remote <iframes> 430 if (!targetFront.isTopLevel) { 431 return; 432 } 433 434 this.storageResources = {}; 435 this.table.clear(); 436 this.hideSidebar(); 437 this.tree.clear(); 438 } 439 440 set animationsEnabled(value) { 441 this._panelDoc.documentElement.classList.toggle("no-animate", !value); 442 } 443 444 destroy() { 445 if (this._destroyed) { 446 return; 447 } 448 this._destroyed = true; 449 450 const { resourceCommand } = this._commands; 451 resourceCommand.unwatchResources(this._listenedResourceTypes, { 452 onAvailable: this._onResourceListAvailable, 453 }); 454 455 this.table.off(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar); 456 this.table.off(TableWidget.EVENTS.SCROLL_END, this.loadMoreItems); 457 this.table.off(TableWidget.EVENTS.CELL_EDIT, this.editItem); 458 this.table.destroy(); 459 460 this._panelDoc.removeEventListener("keypress", this.handleKeypress); 461 this.searchBox.removeEventListener("input", this.filterItems); 462 this.searchBox = null; 463 464 this.sidebarToggleBtn.removeEventListener( 465 "click", 466 this.onPaneToggleButtonClicked 467 ); 468 this.sidebarToggleBtn = null; 469 470 this._window.removeEventListener("resize", this.#onLazyPanelResize, true); 471 472 this._treePopup.removeEventListener( 473 "popupshowing", 474 this.onTreePopupShowing 475 ); 476 this._refreshButton.removeEventListener("click", this.onRefreshTable); 477 this._addButton.removeEventListener("click", this.onAddItem); 478 this._tablePopupAddItem.removeEventListener("command", this.onAddItem); 479 this._treePopupDeleteAll.removeEventListener("command", this.onRemoveAll); 480 this._treePopupDeleteAllSessionCookies.removeEventListener( 481 "command", 482 this.onRemoveAllSessionCookies 483 ); 484 this._treePopupDelete.removeEventListener("command", this.onRemoveTreeItem); 485 486 this._tablePopup.removeEventListener( 487 "popupshowing", 488 this.onTablePopupShowing 489 ); 490 this._tablePopupDelete.removeEventListener("command", this.onRemoveItem); 491 this._tablePopupDeleteAllFrom.removeEventListener( 492 "command", 493 this.onRemoveAllFrom 494 ); 495 this._tablePopupDeleteAll.removeEventListener("command", this.onRemoveAll); 496 this._tablePopupDeleteAllSessionCookies.removeEventListener( 497 "command", 498 this.onRemoveAllSessionCookies 499 ); 500 } 501 502 setupToolbar() { 503 this.searchBox = this._panelDoc.getElementById("storage-searchbox"); 504 this.searchBox.addEventListener("input", this.filterItems); 505 506 // Setup the sidebar toggle button. 507 this.sidebarToggleBtn = this._panelDoc.querySelector(".sidebar-toggle"); 508 this.updateSidebarToggleButton(); 509 510 this.sidebarToggleBtn.addEventListener( 511 "click", 512 this.onPaneToggleButtonClicked 513 ); 514 } 515 516 onPaneToggleButtonClicked() { 517 if (this.sidebar.hidden && this.table.selectedRow) { 518 this.sidebar.hidden = false; 519 this.sidebarToggledOpen = true; 520 this.updateSidebarToggleButton(); 521 } else { 522 this.sidebarToggledOpen = false; 523 this.hideSidebar(); 524 } 525 } 526 527 updateSidebarToggleButton() { 528 let dataL10nId; 529 this.sidebarToggleBtn.hidden = !this.table.hasSelectedRow; 530 531 if (this.sidebar.hidden) { 532 this.sidebarToggleBtn.classList.add("pane-collapsed"); 533 dataL10nId = "storage-expand-pane"; 534 } else { 535 this.sidebarToggleBtn.classList.remove("pane-collapsed"); 536 dataL10nId = "storage-collapse-pane"; 537 } 538 539 this._panelDoc.l10n.setAttributes(this.sidebarToggleBtn, dataL10nId); 540 } 541 542 /** 543 * Hide the object viewer sidebar 544 */ 545 hideSidebar() { 546 this.sidebar.hidden = true; 547 this.updateSidebarToggleButton(); 548 } 549 550 getCurrentFront() { 551 const { datatype, host } = this.table; 552 return this._getStorage(datatype, host); 553 } 554 555 _getStorage(type, host) { 556 const storageType = this.storageResources[type]; 557 return storageType.find(x => host in x.hosts); 558 } 559 560 /** 561 * Make column fields editable 562 * 563 * @param {Array} editableFields 564 * An array of keys of columns to be made editable 565 */ 566 makeFieldsEditable(editableFields) { 567 if (editableFields && editableFields.length) { 568 this.table.makeFieldsEditable(editableFields); 569 } else if (this.table._editableFieldsEngine) { 570 this.table._editableFieldsEngine.destroy(); 571 } 572 } 573 574 async editItem(data, cellEditAbortController) { 575 const selectedItem = this.tree.selectedItem; 576 if (!selectedItem) { 577 return; 578 } 579 const front = this.getCurrentFront(); 580 581 const result = await front.editItem(data); 582 // At the moment, only editing cookies can return an error 583 if (front.typeName === "cookies" && result?.errorString) { 584 const notificationBox = this._toolbox.getNotificationBox(); 585 const message = await this._panelDoc.l10n.formatValue( 586 "storage-cookie-edit-error", 587 { errorString: result.errorString } 588 ); 589 590 notificationBox.appendNotification( 591 message, 592 "storage-cookie-edit-error", 593 null, 594 notificationBox.PRIORITY_WARNING_LOW 595 ); 596 597 // Revert value in table 598 cellEditAbortController.abort(); 599 } 600 } 601 602 /** 603 * Removes the given item from the storage table. Reselects the next item in 604 * the table and repopulates the sidebar with that item's data if the item 605 * being removed was selected. 606 */ 607 async removeItemFromTable(name) { 608 if (this.table.isSelected(name) && this.table.items.size > 1) { 609 if (this.table.selectedIndex == 0) { 610 this.table.selectNextRow(); 611 } else { 612 this.table.selectPreviousRow(); 613 } 614 } 615 616 this.table.remove(name); 617 await this.updateObjectSidebar(); 618 } 619 620 /** 621 * Event handler for "stores-cleared" event coming from the storage actor. 622 * 623 * @param {object} 624 * An object containing which hosts/paths are cleared from a 625 * storage 626 */ 627 _onStoreCleared(resource, { clearedHostsOrPaths }) { 628 const { resourceKey } = resource; 629 function* enumPaths() { 630 if (Array.isArray(clearedHostsOrPaths)) { 631 // Handle the legacy response with array of hosts 632 for (const host of clearedHostsOrPaths) { 633 yield [host]; 634 } 635 } else { 636 // Handle the new format that supports clearing sub-stores in a host 637 for (const host in clearedHostsOrPaths) { 638 const paths = clearedHostsOrPaths[host]; 639 640 if (!paths.length) { 641 yield [host]; 642 } else { 643 for (let path of paths) { 644 try { 645 path = JSON.parse(path); 646 yield [host, ...path]; 647 } catch (ex) { 648 // ignore 649 } 650 } 651 } 652 } 653 } 654 } 655 656 for (const path of enumPaths()) { 657 // Find if the path is selected (there is max one) and clear it 658 if (this.tree.isSelected([resourceKey, ...path])) { 659 this.table.clear(); 660 this.hideSidebar(); 661 662 // Reset itemOffset to 0 so that items added after local storate is 663 // cleared will be shown 664 this.itemOffset = 0; 665 666 this.emit("store-objects-cleared"); 667 break; 668 } 669 } 670 } 671 672 /** 673 * Event handler for "stores-update" event coming from the storage actor. 674 * 675 * @param {object} argument0 676 * An object containing the details of the added, changed and deleted 677 * storage objects. 678 * Each of these 3 objects are of the following format: 679 * { 680 * <store_type1>: { 681 * <host1>: [<store_names1>, <store_name2>...], 682 * <host2>: [<store_names34>...], ... 683 * }, 684 * <store_type2>: { 685 * <host1>: [<store_names1>, <store_name2>...], 686 * <host2>: [<store_names34>...], ... 687 * }, ... 688 * } 689 * Where store_type1 and store_type2 is one of cookies, indexedDB, 690 * sessionStorage and localStorage; host1, host2 are the host in which 691 * this change happened; and [<store_namesX] is an array of the names 692 * of the changed store objects. This array is empty for deleted object 693 * if the host was completely removed. 694 */ 695 async _onStoreUpdate(resource, update) { 696 const { changed, added, deleted } = update; 697 if (added) { 698 await this.handleAddedItems(added); 699 } 700 701 if (changed) { 702 await this.handleChangedItems(changed); 703 } 704 705 // We are dealing with batches of changes here. Deleted **MUST** come last in case it 706 // is in the same batch as added and changed events e.g. 707 // - An item is changed then deleted in the same batch: deleted then changed will 708 // display an item that has been deleted. 709 // - An item is added then deleted in the same batch: deleted then added will 710 // display an item that has been deleted. 711 if (deleted) { 712 await this.handleDeletedItems(deleted); 713 } 714 715 if (added || deleted || changed) { 716 this.emit("store-objects-edit"); 717 } 718 } 719 720 /** 721 * If the panel is resized we need to check if we should load the next batch of 722 * storage items. 723 */ 724 async #onLazyPanelResize() { 725 // We can be called on a closed window or destroyed toolbox because of the 726 // deferred task. 727 if (this._window.closed || this._destroyed || this.table.hasScrollbar) { 728 return; 729 } 730 731 await this.loadMoreItems(); 732 this.emit("storage-resize"); 733 } 734 735 /** 736 * Get a string for a column name automatically choosing whether or not the 737 * string should be localized. 738 * 739 * @param {string} type 740 * The storage type. 741 * @param {string} name 742 * The field name that may need to be localized. 743 */ 744 _getColumnName(type, name) { 745 // If the ID exists in HEADERS_NON_L10N_STRINGS then we do not translate it 746 const columnName = HEADERS_NON_L10N_STRINGS[type]?.[name]; 747 if (columnName) { 748 return columnName; 749 } 750 751 // otherwise we get it from the L10N Map (populated during init) 752 const l10nId = HEADERS_L10N_IDS[type]?.[name]; 753 if (l10nId && this._l10nStrings.has(l10nId)) { 754 return this._l10nStrings.get(l10nId); 755 } 756 757 // If the string isn't localized, we will just use the field name. 758 return name; 759 } 760 761 /** 762 * Handle added items received by onEdit 763 * 764 * @param {object} See onEdit docs 765 */ 766 async handleAddedItems(added) { 767 for (const type in added) { 768 for (const host in added[type]) { 769 const label = this.getReadableLabelFromHostname(host); 770 this.tree.add([type, { id: host, label, type: "url" }]); 771 for (let name of added[type][host]) { 772 try { 773 name = JSON.parse(name); 774 if (name.length == 3) { 775 name.splice(2, 1); 776 } 777 this.tree.add([type, host, ...name]); 778 if (!this.tree.selectedItem) { 779 this.tree.selectedItem = [type, host, name[0], name[1]]; 780 await this.fetchStorageObjects( 781 type, 782 host, 783 [JSON.stringify(name)], 784 REASON.NEW_ROW 785 ); 786 } 787 } catch (ex) { 788 // Do nothing 789 } 790 } 791 792 if (this.tree.isSelected([type, host])) { 793 await this.fetchStorageObjects( 794 type, 795 host, 796 added[type][host], 797 REASON.NEW_ROW 798 ); 799 } 800 } 801 } 802 } 803 804 /** 805 * Handle deleted items received by onEdit 806 * 807 * @param {object} See onEdit docs 808 */ 809 async handleDeletedItems(deleted) { 810 for (const type in deleted) { 811 for (const host in deleted[type]) { 812 if (!deleted[type][host].length) { 813 // This means that the whole host is deleted, thus the item should 814 // be removed from the storage tree 815 if (this.tree.isSelected([type, host])) { 816 this.table.clear(); 817 this.hideSidebar(); 818 this.tree.selectPreviousItem(); 819 } 820 821 this.tree.remove([type, host]); 822 } else { 823 for (const name of deleted[type][host]) { 824 try { 825 if (["indexedDB", "Cache"].includes(type)) { 826 // For indexedDB and Cache, the key is being parsed because 827 // these storages are represented as a tree and the key 828 // used to notify their changes is not a simple string. 829 const names = JSON.parse(name); 830 // Is a whole cache, database or objectstore deleted? 831 // Then remove it from the tree. 832 if (names.length < 3) { 833 if (this.tree.isSelected([type, host, ...names])) { 834 this.table.clear(); 835 this.hideSidebar(); 836 this.tree.selectPreviousItem(); 837 } 838 this.tree.remove([type, host, ...names]); 839 } 840 841 // Remove the item from table if currently displayed. 842 if (names.length) { 843 const tableItemName = names.pop(); 844 if (this.tree.isSelected([type, host, ...names])) { 845 await this.removeItemFromTable(tableItemName); 846 } 847 } 848 } else if (this.tree.isSelected([type, host])) { 849 // For all the other storage types with a simple string key, 850 // remove the item from the table by name without any parsing. 851 await this.removeItemFromTable(name); 852 } 853 } catch (ex) { 854 if (this.tree.isSelected([type, host])) { 855 await this.removeItemFromTable(name); 856 } 857 } 858 } 859 } 860 } 861 } 862 } 863 864 /** 865 * Handle changed items received by onEdit 866 * 867 * @param {object} See onEdit docs 868 */ 869 async handleChangedItems(changed) { 870 const selectedItem = this.tree.selectedItem; 871 if (!selectedItem) { 872 return; 873 } 874 875 const [type, host, db, objectStore] = selectedItem; 876 if (!changed[type] || !changed[type][host] || !changed[type][host].length) { 877 return; 878 } 879 try { 880 const toUpdate = []; 881 for (const name of changed[type][host]) { 882 if (["indexedDB", "Cache"].includes(type)) { 883 // For indexedDB and Cache, the key is being parsed because 884 // these storage are represented as a tree and the key 885 // used to notify their changes is not a simple string. 886 const names = JSON.parse(name); 887 if (names[0] == db && names[1] == objectStore && names[2]) { 888 toUpdate.push(name); 889 } 890 } else { 891 // For all the other storage types with a simple string key, 892 // update the item from the table by name without any parsing. 893 toUpdate.push(name); 894 } 895 } 896 await this.fetchStorageObjects(type, host, toUpdate, REASON.UPDATE); 897 } catch (ex) { 898 await this.fetchStorageObjects( 899 type, 900 host, 901 changed[type][host], 902 REASON.UPDATE 903 ); 904 } 905 } 906 907 /** 908 * Fetches the storage objects from the storage actor and populates the 909 * storage table with the returned data. 910 * 911 * @param {string} type 912 * The type of storage. Ex. "cookies" 913 * @param {string} host 914 * Hostname 915 * @param {Array} names 916 * Names of particular store objects. Empty if all are requested 917 * @param {Constant} reason 918 * See REASON constant at top of file. 919 */ 920 async fetchStorageObjects(type, host, names, reason) { 921 const fetchOpts = 922 reason === REASON.NEXT_50_ITEMS ? { offset: this.itemOffset } : {}; 923 fetchOpts.sessionString = standardSessionString; 924 const storage = this._getStorage(type, host); 925 this.sidebarToggledOpen = null; 926 927 if ( 928 reason !== REASON.NEXT_50_ITEMS && 929 reason !== REASON.UPDATE && 930 reason !== REASON.NEW_ROW && 931 reason !== REASON.POPULATE 932 ) { 933 throw new Error("Invalid reason specified"); 934 } 935 936 try { 937 if ( 938 reason === REASON.POPULATE || 939 (reason === REASON.NEW_ROW && this.table.items.size === 0) 940 ) { 941 let subType = null; 942 // The indexedDB type could have sub-type data to fetch. 943 // If having names specified, then it means 944 // we are fetching details of specific database or of object store. 945 if (type === "indexedDB" && names) { 946 const [dbName, objectStoreName] = JSON.parse(names[0]); 947 if (dbName) { 948 subType = "database"; 949 } 950 if (objectStoreName) { 951 subType = "object store"; 952 } 953 } 954 955 await this.resetColumns(type, host, subType); 956 } 957 958 const { data, total } = await storage.getStoreObjects( 959 host, 960 names, 961 fetchOpts 962 ); 963 if (data.length) { 964 await this.populateTable(data, reason, total); 965 } else if (reason === REASON.POPULATE) { 966 await this.clearHeaders(); 967 } 968 this.updateToolbar(); 969 this.emit("store-objects-updated"); 970 } catch (ex) { 971 console.error(ex); 972 } 973 } 974 975 supportsAddItem(type, host) { 976 const storage = this._getStorage(type, host); 977 return storage?.traits.supportsAddItem || false; 978 } 979 980 supportsRemoveItem(type, host) { 981 const storage = this._getStorage(type, host); 982 return storage?.traits.supportsRemoveItem || false; 983 } 984 985 supportsRemoveAll(type, host) { 986 const storage = this._getStorage(type, host); 987 return storage?.traits.supportsRemoveAll || false; 988 } 989 990 supportsRemoveAllSessionCookies(type, host) { 991 const storage = this._getStorage(type, host); 992 return storage?.traits.supportsRemoveAllSessionCookies || false; 993 } 994 995 /** 996 * Updates the toolbar hiding and showing buttons as appropriate. 997 */ 998 updateToolbar() { 999 const item = this.tree.selectedItem; 1000 if (!item) { 1001 return; 1002 } 1003 1004 const [type, host] = item; 1005 1006 // Add is only supported if the selected item has a host. 1007 this._addButton.hidden = !host || !this.supportsAddItem(type, host); 1008 } 1009 1010 /** 1011 * Populates the storage tree which displays the list of storages present for 1012 * the page. 1013 */ 1014 async populateStorageTree() { 1015 const populateTreeFromResource = (type, resource) => { 1016 for (const host in resource.hosts) { 1017 const label = this.getReadableLabelFromHostname(host); 1018 this.tree.add([type, { id: host, label, type: "url" }]); 1019 for (const name of resource.hosts[host]) { 1020 try { 1021 const names = JSON.parse(name); 1022 this.tree.add([type, host, ...names]); 1023 if (!this.tree.selectedItem) { 1024 this.tree.selectedItem = [type, host, names[0], names[1]]; 1025 } 1026 } catch (ex) { 1027 // Do Nothing 1028 } 1029 } 1030 if (!this.tree.selectedItem) { 1031 this.tree.selectedItem = [type, host]; 1032 } 1033 } 1034 }; 1035 1036 // When can we expect the "store-objects-updated" event? 1037 // -> TreeWidget setter `selectedItem` emits a "select" event 1038 // -> on tree "select" event, this module calls `onHostSelect` 1039 // -> finally `onHostSelect` calls `fetchStorageObjects`, which will emit 1040 // "store-objects-updated" at the end of the method. 1041 // So if the selection changed, we can wait for "store-objects-updated", 1042 // which is emitted at the end of `fetchStorageObjects`. 1043 const onStoresObjectsUpdated = this.once("store-objects-updated"); 1044 1045 // Save the initially selected item to check if tree.selected was updated, 1046 // see comment above. 1047 const initialSelectedItem = this.tree.selectedItem; 1048 1049 for (const [type, resources] of Object.entries(this.storageResources)) { 1050 let typeLabel = type; 1051 try { 1052 typeLabel = this.getStorageTypeLabel(type); 1053 } catch (e) { 1054 console.error("Unable to localize tree label type:" + type); 1055 } 1056 1057 this.tree.add([{ id: type, label: typeLabel, type: "store" }]); 1058 1059 // storageResources values are arrays, with storage resources. 1060 // we may have many storage resources per type if we get remote iframes. 1061 for (const resource of resources) { 1062 populateTreeFromResource(type, resource); 1063 } 1064 } 1065 1066 if (initialSelectedItem !== this.tree.selectedItem) { 1067 await onStoresObjectsUpdated; 1068 } 1069 } 1070 1071 getStorageTypeLabel(type) { 1072 let dataL10nId; 1073 1074 switch (type) { 1075 case "cookies": 1076 dataL10nId = "storage-tree-labels-cookies"; 1077 break; 1078 case "localStorage": 1079 dataL10nId = "storage-tree-labels-local-storage"; 1080 break; 1081 case "sessionStorage": 1082 dataL10nId = "storage-tree-labels-session-storage"; 1083 break; 1084 case "indexedDB": 1085 dataL10nId = "storage-tree-labels-indexed-db"; 1086 break; 1087 case "Cache": 1088 dataL10nId = "storage-tree-labels-cache"; 1089 break; 1090 case "extensionStorage": 1091 dataL10nId = "storage-tree-labels-extension-storage"; 1092 break; 1093 default: 1094 throw new Error("Unknown storage type"); 1095 } 1096 1097 return this._l10nStrings.get(dataL10nId); 1098 } 1099 1100 /** 1101 * Populates the selected entry from the table in the sidebar for a more 1102 * detailed view. 1103 */ 1104 /* eslint-disable-next-line */ 1105 async updateObjectSidebar() { 1106 const item = this.table.selectedRow; 1107 let value; 1108 1109 // Get the string value (async action) and the update the UI synchronously. 1110 if ((item?.name || item?.name === "") && item?.valueActor) { 1111 value = await item.valueActor.string(); 1112 } 1113 1114 // Bail if the selectedRow is no longer selected, the item doesn't exist or the state 1115 // changed in another way during the above yield. 1116 if ( 1117 this.table.items.size === 0 || 1118 !item || 1119 !this.table.selectedRow || 1120 item.uniqueKey !== this.table.selectedRow.uniqueKey 1121 ) { 1122 this.hideSidebar(); 1123 return; 1124 } 1125 1126 // Start updating the UI. Everything is sync beyond this point. 1127 if (this.sidebarToggledOpen === null || this.sidebarToggledOpen === true) { 1128 this.sidebar.hidden = false; 1129 } 1130 1131 this.updateSidebarToggleButton(); 1132 this.view.empty(); 1133 const mainScope = this.view.addScope("storage-data"); 1134 mainScope.expanded = true; 1135 1136 if (value) { 1137 const itemVar = mainScope.addItem(item.name + "", {}, { relaxed: true }); 1138 1139 // The main area where the value will be displayed 1140 itemVar.setGrip(value); 1141 1142 // May be the item value is a json or a key value pair itself 1143 const obj = parseItemValue(value); 1144 if (typeof obj === "object") { 1145 this.populateSidebar(item.name, obj); 1146 } 1147 1148 // By default the item name and value are shown. If this is the only 1149 // information available, then nothing else is to be displayed. 1150 const itemProps = Object.keys(item); 1151 if (itemProps.length > 3) { 1152 // Display any other information other than the item name and value 1153 // which may be available. 1154 const rawObject = Object.create(null); 1155 const otherProps = itemProps.filter( 1156 e => !["name", "value", "valueActor"].includes(e) 1157 ); 1158 for (const prop of otherProps) { 1159 const column = this.table.columns.get(prop); 1160 if (column?.private) { 1161 continue; 1162 } 1163 1164 const fieldName = this._getColumnName(this.table.datatype, prop); 1165 rawObject[fieldName] = item[prop]; 1166 } 1167 itemVar.populate(rawObject, { sorted: true }); 1168 itemVar.twisty = true; 1169 itemVar.expanded = true; 1170 } 1171 } else { 1172 // Case when displaying IndexedDB db/object store properties. 1173 for (const key in item) { 1174 const column = this.table.columns.get(key); 1175 if (column?.private) { 1176 continue; 1177 } 1178 1179 mainScope.addItem(key, {}, true).setGrip(item[key]); 1180 const obj = parseItemValue(item[key]); 1181 if (typeof obj === "object") { 1182 this.populateSidebar(item.name, obj); 1183 } 1184 } 1185 } 1186 1187 this.emit("sidebar-updated"); 1188 } 1189 1190 /** 1191 * Gets a readable label from the hostname. If the hostname is a Punycode 1192 * domain(I.e. an ASCII domain name representing a Unicode domain name), then 1193 * this function decodes it to the readable Unicode domain name, and label 1194 * the Unicode domain name toggether with the original domian name, and then 1195 * return the label; if the hostname isn't a Punycode domain(I.e. it isn't 1196 * encoded and is readable on its own), then this function simply returns the 1197 * original hostname. 1198 * 1199 * @param {string} host 1200 * The string representing a host, e.g, example.com, example.com:8000 1201 */ 1202 getReadableLabelFromHostname(host) { 1203 try { 1204 const { hostname } = new URL(host); 1205 const unicodeHostname = getUnicodeHostname(hostname); 1206 if (hostname !== unicodeHostname) { 1207 // If the hostname is a Punycode domain representing a Unicode domain, 1208 // we decode it to the Unicode domain name, and then label the Unicode 1209 // domain name together with the original domain name. 1210 return host.replace(hostname, unicodeHostname) + " [ " + host + " ]"; 1211 } 1212 } catch (_) { 1213 // Skip decoding for a host which doesn't include a domain name, simply 1214 // consider them to be readable. 1215 } 1216 return host; 1217 } 1218 1219 /** 1220 * Populates the sidebar with a parsed object. 1221 * 1222 * @param {object} obj - Either a json or a key-value separated object or a 1223 * key separated array 1224 */ 1225 populateSidebar(name, obj) { 1226 const jsonObject = Object.create(null); 1227 const view = this.view; 1228 jsonObject[name] = obj; 1229 const valueScope = 1230 view.getScopeAtIndex(1) || view.addScope("storage-parsed-value"); 1231 valueScope.expanded = true; 1232 const jsonVar = valueScope.addItem("", Object.create(null), { 1233 relaxed: true, 1234 }); 1235 jsonVar.expanded = true; 1236 jsonVar.twisty = true; 1237 jsonVar.populate(jsonObject, { expanded: true }); 1238 } 1239 1240 /** 1241 * Select handler for the storage tree. Fetches details of the selected item 1242 * from the storage details and populates the storage tree. 1243 * 1244 * @param {Array} item 1245 * An array of ids which represent the location of the selected item in 1246 * the storage tree 1247 */ 1248 async onHostSelect(item) { 1249 if (!item) { 1250 return; 1251 } 1252 1253 this.table.clear(); 1254 this.hideSidebar(); 1255 this.searchBox.value = ""; 1256 1257 const [type, host] = item; 1258 this.table.host = host; 1259 this.table.datatype = type; 1260 1261 this.updateToolbar(); 1262 1263 let names = null; 1264 if (!host) { 1265 let storageTypeHintL10nId = ""; 1266 switch (type) { 1267 case "Cache": 1268 storageTypeHintL10nId = "storage-table-type-cache-hint"; 1269 break; 1270 case "cookies": 1271 storageTypeHintL10nId = "storage-table-type-cookies-hint"; 1272 break; 1273 case "extensionStorage": 1274 storageTypeHintL10nId = "storage-table-type-extensionstorage-hint"; 1275 break; 1276 case "localStorage": 1277 storageTypeHintL10nId = "storage-table-type-localstorage-hint"; 1278 break; 1279 case "indexedDB": 1280 storageTypeHintL10nId = "storage-table-type-indexeddb-hint"; 1281 break; 1282 case "sessionStorage": 1283 storageTypeHintL10nId = "storage-table-type-sessionstorage-hint"; 1284 break; 1285 } 1286 this.table.setPlaceholder( 1287 storageTypeHintL10nId, 1288 getStorageTypeURL(this.table.datatype) 1289 ); 1290 1291 // If selected item has no host then reset table headers 1292 await this.clearHeaders(); 1293 return; 1294 } 1295 if (item.length > 2) { 1296 names = [JSON.stringify(item.slice(2))]; 1297 } 1298 1299 this.itemOffset = 0; 1300 await this.fetchStorageObjects(type, host, names, REASON.POPULATE); 1301 } 1302 1303 /** 1304 * Clear the column headers in the storage table 1305 */ 1306 async clearHeaders() { 1307 this.table.setColumns({}, null, {}, {}); 1308 } 1309 1310 /** 1311 * Resets the column headers in the storage table with the pased object `data` 1312 * 1313 * @param {string} type 1314 * The type of storage corresponding to the after-reset columns in the 1315 * table. 1316 * @param {string} host 1317 * The host name corresponding to the table after reset. 1318 * 1319 * @param {string} [subType] 1320 * The sub type under the given type. 1321 */ 1322 async resetColumns(type, host, subtype) { 1323 this.table.host = host; 1324 this.table.datatype = type; 1325 1326 let uniqueKey = null; 1327 const columns = {}; 1328 const editableFields = []; 1329 const hiddenFields = []; 1330 const privateFields = []; 1331 const fields = await this.getCurrentFront().getFields(subtype); 1332 1333 fields.forEach(f => { 1334 if (!uniqueKey) { 1335 this.table.uniqueId = uniqueKey = f.name; 1336 } 1337 1338 if (f.editable) { 1339 editableFields.push(f.name); 1340 } 1341 1342 if (f.hidden) { 1343 hiddenFields.push(f.name); 1344 } 1345 1346 if (f.private) { 1347 privateFields.push(f.name); 1348 } 1349 1350 const columnName = this._getColumnName(type, f.name); 1351 if (columnName) { 1352 columns[f.name] = columnName; 1353 } else if (!f.private) { 1354 // Private fields are only displayed when running tests so there is no 1355 // need to log an error if they are not localized. 1356 columns[f.name] = f.name; 1357 console.error( 1358 `No string defined in HEADERS_NON_L10N_STRINGS for '${type}.${f.name}'` 1359 ); 1360 } 1361 }); 1362 1363 this.table.setColumns(columns, null, hiddenFields, privateFields); 1364 this.hideSidebar(); 1365 1366 this.makeFieldsEditable(editableFields); 1367 } 1368 1369 /** 1370 * Populates or updates the rows in the storage table. 1371 * 1372 * @param {Array[object]} data 1373 * Array of objects to be populated in the storage table 1374 * @param {Constant} reason 1375 * See REASON constant at top of file. 1376 * @param {number} totalAvailable 1377 * The total number of items available in the current storage type. 1378 */ 1379 async populateTable(data, reason, totalAvailable) { 1380 for (const item of data) { 1381 if (item.value) { 1382 item.valueActor = item.value; 1383 item.value = item.value.initial || ""; 1384 } 1385 if (item.expires != null) { 1386 item.expires = item.expires 1387 ? new Date(item.expires).toUTCString() 1388 : this._l10nStrings.get("storage-expires-session"); 1389 } 1390 if (item.creationTime != null) { 1391 item.creationTime = new Date(item.creationTime).toUTCString(); 1392 } 1393 if (item.updateTime != null) { 1394 item.updateTime = new Date(item.updateTime).toUTCString(); 1395 } 1396 if (item.lastAccessed != null) { 1397 item.lastAccessed = new Date(item.lastAccessed).toUTCString(); 1398 } 1399 1400 switch (reason) { 1401 case REASON.POPULATE: 1402 case REASON.NEXT_50_ITEMS: 1403 // Update without flashing the row. 1404 this.table.push(item, true); 1405 break; 1406 case REASON.NEW_ROW: 1407 // Update and flash the row. 1408 this.table.push(item, false); 1409 break; 1410 case REASON.UPDATE: 1411 this.table.update(item); 1412 if (item == this.table.selectedRow && !this.sidebar.hidden) { 1413 await this.updateObjectSidebar(); 1414 } 1415 break; 1416 } 1417 1418 this.shouldLoadMoreItems = true; 1419 } 1420 1421 if ( 1422 (reason === REASON.POPULATE || reason === REASON.NEXT_50_ITEMS) && 1423 this.table.items.size < totalAvailable && 1424 !this.table.hasScrollbar 1425 ) { 1426 await this.loadMoreItems(); 1427 } 1428 } 1429 1430 /** 1431 * Handles keypress event on the body table to close the sidebar when open 1432 * 1433 * @param {DOMEvent} event 1434 * The event passed by the keypress event. 1435 */ 1436 handleKeypress(event) { 1437 if (event.keyCode == KeyCodes.DOM_VK_ESCAPE) { 1438 if (!this.sidebar.hidden) { 1439 this.hideSidebar(); 1440 this.sidebarToggledOpen = false; 1441 // Stop Propagation to prevent opening up of split console 1442 event.stopPropagation(); 1443 event.preventDefault(); 1444 } 1445 } else if ( 1446 event.keyCode == KeyCodes.DOM_VK_BACK_SPACE || 1447 event.keyCode == KeyCodes.DOM_VK_DELETE 1448 ) { 1449 if (this.table.selectedRow && event.target.localName != "input") { 1450 this.onRemoveItem(event); 1451 event.stopPropagation(); 1452 event.preventDefault(); 1453 } 1454 } 1455 } 1456 1457 /** 1458 * Handles filtering the table 1459 */ 1460 filterItems() { 1461 const value = this.searchBox.value; 1462 this.table.filterItems(value, ["valueActor"]); 1463 this._panelDoc.documentElement.classList.toggle("filtering", !!value); 1464 } 1465 1466 /** 1467 * Load the next batch of 50 items 1468 */ 1469 async loadMoreItems() { 1470 if ( 1471 !this.shouldLoadMoreItems || 1472 this._toolbox.currentToolId !== "storage" || 1473 !this.tree.selectedItem 1474 ) { 1475 return; 1476 } 1477 this.shouldLoadMoreItems = false; 1478 this.itemOffset += 50; 1479 1480 const item = this.tree.selectedItem; 1481 const [type, host] = item; 1482 let names = null; 1483 if (item.length > 2) { 1484 names = [JSON.stringify(item.slice(2))]; 1485 } 1486 await this.fetchStorageObjects(type, host, names, REASON.NEXT_50_ITEMS); 1487 } 1488 1489 /** 1490 * Fires before a cell context menu with the "Add" or "Delete" action is 1491 * shown. If the currently selected storage object doesn't support adding or 1492 * removing items, prevent showing the menu. 1493 */ 1494 onTablePopupShowing(event) { 1495 const selectedItem = this.tree.selectedItem; 1496 const [type, host] = selectedItem; 1497 1498 // IndexedDB only supports removing items from object stores (level 4 of the tree) 1499 if ( 1500 (!this.supportsAddItem(type, host) && 1501 !this.supportsRemoveItem(type, host)) || 1502 (type === "indexedDB" && selectedItem.length !== 4) 1503 ) { 1504 event.preventDefault(); 1505 return; 1506 } 1507 1508 const rowId = this.table.contextMenuRowId; 1509 const data = this.table.items.get(rowId); 1510 1511 if (this.supportsRemoveItem(type, host)) { 1512 const name = data[this.table.uniqueId]; 1513 const separatorRegex = new RegExp(SEPARATOR_GUID, "g"); 1514 const label = addEllipsis((name + "").replace(separatorRegex, "-")); 1515 1516 this._panelDoc.l10n.setArgs(this._tablePopupDelete, { itemName: label }); 1517 this._tablePopupDelete.hidden = false; 1518 } else { 1519 this._tablePopupDelete.hidden = true; 1520 } 1521 1522 this._tablePopupAddItem.hidden = !this.supportsAddItem(type, host); 1523 1524 let showDeleteAllSessionCookies = false; 1525 if (this.supportsRemoveAllSessionCookies(type, host)) { 1526 if (selectedItem.length === 2) { 1527 showDeleteAllSessionCookies = true; 1528 } 1529 } 1530 1531 this._tablePopupDeleteAllSessionCookies.hidden = 1532 !showDeleteAllSessionCookies; 1533 1534 if (type === "cookies") { 1535 const hostString = addEllipsis(data.host); 1536 1537 this._panelDoc.l10n.setArgs(this._tablePopupDeleteAllFrom, { 1538 host: hostString, 1539 }); 1540 this._tablePopupDeleteAllFrom.hidden = false; 1541 } else { 1542 this._tablePopupDeleteAllFrom.hidden = true; 1543 } 1544 } 1545 1546 onTreePopupShowing(event) { 1547 let showMenu = false; 1548 const selectedItem = this.tree.selectedItem; 1549 1550 if (selectedItem) { 1551 const [type, host] = selectedItem; 1552 1553 // The delete all (aka clear) action is displayed for IndexedDB object stores 1554 // (level 4 of tree), for Cache objects (level 3) and for the whole host (level 2) 1555 // for other storage types (cookies, localStorage, ...). 1556 let showDeleteAll = false; 1557 if (this.supportsRemoveAll(type, host)) { 1558 let level; 1559 if (type == "indexedDB") { 1560 level = 4; 1561 } else if (type == "Cache") { 1562 level = 3; 1563 } else { 1564 level = 2; 1565 } 1566 1567 if (selectedItem.length == level) { 1568 showDeleteAll = true; 1569 } 1570 } 1571 1572 this._treePopupDeleteAll.hidden = !showDeleteAll; 1573 1574 // The delete all session cookies action is displayed for cookie object stores 1575 // (level 2 of tree) 1576 let showDeleteAllSessionCookies = false; 1577 if (this.supportsRemoveAllSessionCookies(type, host)) { 1578 if (type === "cookies" && selectedItem.length === 2) { 1579 showDeleteAllSessionCookies = true; 1580 } 1581 } 1582 1583 this._treePopupDeleteAllSessionCookies.hidden = 1584 !showDeleteAllSessionCookies; 1585 1586 // The delete action is displayed for: 1587 // - IndexedDB databases (level 3 of the tree) 1588 // - Cache objects (level 3 of the tree) 1589 const showDelete = 1590 (type == "indexedDB" || type == "Cache") && selectedItem.length == 3; 1591 this._treePopupDelete.hidden = !showDelete; 1592 if (showDelete) { 1593 const itemName = addEllipsis(selectedItem[selectedItem.length - 1]); 1594 this._panelDoc.l10n.setArgs(this._treePopupDelete, { itemName }); 1595 } 1596 1597 showMenu = showDeleteAll || showDelete; 1598 } 1599 1600 if (!showMenu) { 1601 event.preventDefault(); 1602 } 1603 } 1604 1605 onVariableViewPopupShowing() { 1606 const item = this.view.getFocusedItem(); 1607 this._variableViewPopupCopy.toggleAttribute("disabled", !item); 1608 } 1609 1610 /** 1611 * Handles refreshing the selected storage 1612 */ 1613 async onRefreshTable() { 1614 await this.onHostSelect(this.tree.selectedItem); 1615 } 1616 1617 /** 1618 * Handles adding an item from the storage 1619 */ 1620 async onAddItem() { 1621 const selectedItem = this.tree.selectedItem; 1622 if (!selectedItem) { 1623 return; 1624 } 1625 1626 const front = this.getCurrentFront(); 1627 const [, host] = selectedItem; 1628 1629 // Prepare to scroll into view. 1630 this.table.scrollIntoViewOnUpdate = true; 1631 this.table.editBookmark = createGUID(); 1632 const result = await front.addItem(this.table.editBookmark, host); 1633 1634 // At the moment, only adding cookies can (theorically) return an error (although in 1635 // practice, since we set the default properties of the cookie ourselves, this shouldn't 1636 // happen). 1637 if (front.typeName === "cookies" && result?.errorString) { 1638 const notificationBox = this._toolbox.getNotificationBox(); 1639 const message = await this._panelDoc.l10n.formatValue( 1640 "storage-cookie-create-error", 1641 { errorString: result.errorString } 1642 ); 1643 1644 notificationBox.appendNotification( 1645 message, 1646 "storage-cookie-create-error", 1647 null, 1648 notificationBox.PRIORITY_WARNING_LOW 1649 ); 1650 } 1651 } 1652 1653 /** 1654 * Handles copy an item from the storage 1655 */ 1656 onCopyItem() { 1657 this.view._copyItem(); 1658 } 1659 1660 /** 1661 * Handles removing an item from the storage 1662 * 1663 * @param {DOMEvent} event 1664 * The event passed by the command or keypress event. 1665 */ 1666 onRemoveItem(event) { 1667 const [, host, ...path] = this.tree.selectedItem; 1668 const front = this.getCurrentFront(); 1669 const uniqueId = this.table.uniqueId; 1670 const rowId = 1671 event.type == "command" 1672 ? this.table.contextMenuRowId 1673 : this.table.selectedRow[uniqueId]; 1674 const data = this.table.items.get(rowId); 1675 1676 let name = data[uniqueId]; 1677 if (path.length) { 1678 name = JSON.stringify([...path, name]); 1679 } 1680 front.removeItem(host, name); 1681 1682 return false; 1683 } 1684 1685 /** 1686 * Handles removing all items from the storage 1687 */ 1688 onRemoveAll() { 1689 // Cannot use this.currentActor() if the handler is called from the 1690 // tree context menu: it returns correct value only after the table 1691 // data from server are successfully fetched (and that's async). 1692 const [, host, ...path] = this.tree.selectedItem; 1693 const front = this.getCurrentFront(); 1694 const name = path.length ? JSON.stringify(path) : undefined; 1695 front.removeAll(host, name); 1696 } 1697 1698 /** 1699 * Handles removing all session cookies from the storage 1700 */ 1701 onRemoveAllSessionCookies() { 1702 // Cannot use this.currentActor() if the handler is called from the 1703 // tree context menu: it returns the correct value only after the 1704 // table data from server is successfully fetched (and that's async). 1705 const [, host, ...path] = this.tree.selectedItem; 1706 const front = this.getCurrentFront(); 1707 const name = path.length ? JSON.stringify(path) : undefined; 1708 front.removeAllSessionCookies(host, name); 1709 } 1710 1711 /** 1712 * Handles removing all cookies with exactly the same domain as the 1713 * cookie in the selected row. 1714 */ 1715 onRemoveAllFrom() { 1716 const [, host] = this.tree.selectedItem; 1717 const front = this.getCurrentFront(); 1718 const rowId = this.table.contextMenuRowId; 1719 const data = this.table.items.get(rowId); 1720 1721 front.removeAll(host, data.host); 1722 } 1723 1724 onRemoveTreeItem() { 1725 const [type, host, ...path] = this.tree.selectedItem; 1726 1727 if (type == "indexedDB" && path.length == 1) { 1728 this.removeDatabase(host, path[0]); 1729 } else if (type == "Cache" && path.length == 1) { 1730 this.removeCache(host, path[0]); 1731 } 1732 } 1733 1734 async removeDatabase(host, dbName) { 1735 const front = this.getCurrentFront(); 1736 1737 try { 1738 const result = await front.removeDatabase(host, dbName); 1739 if (result.blocked) { 1740 const notificationBox = this._toolbox.getNotificationBox(); 1741 const message = await this._panelDoc.l10n.formatValue( 1742 "storage-idb-delete-blocked", 1743 { dbName } 1744 ); 1745 1746 notificationBox.appendNotification( 1747 message, 1748 "storage-idb-delete-blocked", 1749 null, 1750 notificationBox.PRIORITY_WARNING_LOW 1751 ); 1752 } 1753 } catch (error) { 1754 const notificationBox = this._toolbox.getNotificationBox(); 1755 const message = await this._panelDoc.l10n.formatValue( 1756 "storage-idb-delete-error", 1757 { dbName } 1758 ); 1759 notificationBox.appendNotification( 1760 message, 1761 "storage-idb-delete-error", 1762 null, 1763 notificationBox.PRIORITY_CRITICAL_LOW 1764 ); 1765 } 1766 } 1767 1768 removeCache(host, cacheName) { 1769 const front = this.getCurrentFront(); 1770 1771 front.removeItem(host, JSON.stringify([cacheName])); 1772 } 1773 } 1774 1775 exports.StorageUI = StorageUI; 1776 1777 // Helper Functions 1778 1779 function createGUID() { 1780 return "{cccccccc-cccc-4ccc-yccc-cccccccccccc}".replace(/[cy]/g, c => { 1781 const r = (Math.random() * 16) | 0; 1782 const v = c == "c" ? r : (r & 0x3) | 0x8; 1783 return v.toString(16); 1784 }); 1785 } 1786 1787 function addEllipsis(name) { 1788 if (name.length > ITEM_NAME_MAX_LENGTH) { 1789 if (/^https?:/.test(name)) { 1790 // For URLs, add ellipsis in the middle 1791 const halfLen = ITEM_NAME_MAX_LENGTH / 2; 1792 return name.slice(0, halfLen) + ELLIPSIS + name.slice(-halfLen); 1793 } 1794 1795 // For other strings, add ellipsis at the end 1796 return name.substr(0, ITEM_NAME_MAX_LENGTH) + ELLIPSIS; 1797 } 1798 1799 return name; 1800 }