PushService.sys.mjs (48715B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; 6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 7 import { ChromePushSubscription } from "./ChromePushSubscription.sys.mjs"; 8 9 const lazy = {}; 10 11 XPCOMUtils.defineLazyServiceGetter( 12 lazy, 13 "gPushNotifier", 14 "@mozilla.org/push/Notifier;1", 15 Ci.nsIPushNotifier 16 ); 17 ChromeUtils.defineESModuleGetters(lazy, { 18 PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs", 19 PushServiceWebSocket: "resource://gre/modules/PushServiceWebSocket.sys.mjs", 20 pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs", 21 }); 22 23 ChromeUtils.defineLazyGetter(lazy, "console", () => { 24 return console.createInstance({ 25 maxLogLevelPref: "dom.push.loglevel", 26 prefix: "PushService", 27 }); 28 }); 29 30 const prefs = Services.prefs.getBranch("dom.push."); 31 32 const PUSH_SERVICE_UNINIT = 0; 33 const PUSH_SERVICE_INIT = 1; // No serverURI 34 const PUSH_SERVICE_ACTIVATING = 2; // activating db 35 const PUSH_SERVICE_CONNECTION_DISABLE = 3; 36 const PUSH_SERVICE_ACTIVE_OFFLINE = 4; 37 const PUSH_SERVICE_RUNNING = 5; 38 39 /** 40 * State is change only in couple of functions: 41 * init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT 42 * changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL 43 * present or PUSH_SERVICE_INIT if not present. 44 * changeStateConnectionEnabledEvent - it is call on pref change or during 45 * the service activation and it can 46 * change state to 47 * PUSH_SERVICE_CONNECTION_DISABLE 48 * changeStateOfflineEvent - it is called when offline state changes or during 49 * the service activation and it change state to 50 * PUSH_SERVICE_ACTIVE_OFFLINE or 51 * PUSH_SERVICE_RUNNING. 52 * uninit - change state to PUSH_SERVICE_UNINIT. 53 */ 54 55 // This is for starting and stopping service. 56 const STARTING_SERVICE_EVENT = 0; 57 const CHANGING_SERVICE_EVENT = 1; 58 const STOPPING_SERVICE_EVENT = 2; 59 const UNINIT_EVENT = 3; 60 61 // Returns the backend for the given server URI. 62 function getServiceForServerURI(uri) { 63 // Insecure server URLs are allowed for development and testing. 64 let allowInsecure = prefs.getBoolPref( 65 "testing.allowInsecureServerURL", 66 false 67 ); 68 if (uri.scheme == "wss" || (allowInsecure && uri.scheme == "ws")) { 69 return lazy.PushServiceWebSocket; 70 } 71 return null; 72 } 73 74 /** 75 * Annotates an error with an XPCOM result code. We use this helper 76 * instead of `Components.Exception` because the latter can assert in 77 * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown. 78 */ 79 function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) { 80 let error = new Error(message); 81 error.result = result; 82 return error; 83 } 84 85 /** 86 * The implementation of the push system. It uses WebSockets 87 * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB) 88 * for persistence. 89 */ 90 export var PushService = { 91 _service: null, 92 _state: PUSH_SERVICE_UNINIT, 93 _db: null, 94 _options: null, 95 _visibleNotifications: new Map(), 96 97 // Callback that is called after attempting to 98 // reduce the quota for a record. Used for testing purposes. 99 _updateQuotaTestCallback: null, 100 101 // Set of timeout ID of tasks to reduce quota. 102 _updateQuotaTimeouts: new Set(), 103 104 // When serverURI changes (this is used for testing), db is cleaned up and a 105 // a new db is started. This events must be sequential. 106 _stateChangeProcessQueue: null, 107 _stateChangeProcessEnqueue(op) { 108 if (!this._stateChangeProcessQueue) { 109 this._stateChangeProcessQueue = Promise.resolve(); 110 } 111 112 this._stateChangeProcessQueue = this._stateChangeProcessQueue 113 .then(op) 114 .catch(error => { 115 lazy.console.error( 116 "stateChangeProcessEnqueue: Error transitioning state", 117 error 118 ); 119 return this._shutdownService(); 120 }) 121 .catch(error => { 122 lazy.console.error( 123 "stateChangeProcessEnqueue: Error shutting down service", 124 error 125 ); 126 }); 127 return this._stateChangeProcessQueue; 128 }, 129 130 // Pending request. If a worker try to register for the same scope again, do 131 // not send a new registration request. Therefore we need queue of pending 132 // register requests. This is the list of scopes which pending registration. 133 _pendingRegisterRequest: {}, 134 _notifyActivated: null, 135 _activated: null, 136 _checkActivated() { 137 if (this._state < PUSH_SERVICE_ACTIVATING) { 138 return Promise.reject(new Error("Push service not active")); 139 } 140 if (this._state > PUSH_SERVICE_ACTIVATING) { 141 return Promise.resolve(); 142 } 143 if (!this._activated) { 144 this._activated = new Promise((resolve, reject) => { 145 this._notifyActivated = { resolve, reject }; 146 }); 147 } 148 return this._activated; 149 }, 150 151 _makePendingKey(aPageRecord) { 152 return aPageRecord.scope + "|" + aPageRecord.originAttributes; 153 }, 154 155 _lookupOrPutPendingRequest(aPageRecord) { 156 let key = this._makePendingKey(aPageRecord); 157 if (this._pendingRegisterRequest[key]) { 158 return this._pendingRegisterRequest[key]; 159 } 160 161 return (this._pendingRegisterRequest[key] = 162 this._registerWithServer(aPageRecord)); 163 }, 164 165 _deletePendingRequest(aPageRecord) { 166 let key = this._makePendingKey(aPageRecord); 167 if (this._pendingRegisterRequest[key]) { 168 delete this._pendingRegisterRequest[key]; 169 } 170 }, 171 172 _setState(aNewState) { 173 lazy.console.debug( 174 "setState()", 175 "new state", 176 aNewState, 177 "old state", 178 this._state 179 ); 180 181 if (this._state == aNewState) { 182 return; 183 } 184 185 if (this._state == PUSH_SERVICE_ACTIVATING) { 186 // It is not important what is the new state as soon as we leave 187 // PUSH_SERVICE_ACTIVATING 188 if (this._notifyActivated) { 189 if (aNewState < PUSH_SERVICE_ACTIVATING) { 190 this._notifyActivated.reject(new Error("Push service not active")); 191 } else { 192 this._notifyActivated.resolve(); 193 } 194 } 195 this._notifyActivated = null; 196 this._activated = null; 197 } 198 this._state = aNewState; 199 }, 200 201 async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) { 202 lazy.console.debug("changeStateOfflineEvent()", offline); 203 204 if ( 205 this._state < PUSH_SERVICE_ACTIVE_OFFLINE && 206 this._state != PUSH_SERVICE_ACTIVATING && 207 !calledFromConnEnabledEvent 208 ) { 209 return; 210 } 211 212 if (offline) { 213 if (this._state == PUSH_SERVICE_RUNNING) { 214 this._service.disconnect(); 215 } 216 this._setState(PUSH_SERVICE_ACTIVE_OFFLINE); 217 return; 218 } 219 220 if (this._state == PUSH_SERVICE_RUNNING) { 221 // PushService was not in the offline state, but got notification to 222 // go online (a offline notification has not been sent). 223 // Disconnect first. 224 this._service.disconnect(); 225 } 226 227 let broadcastListeners = await lazy.pushBroadcastService.getListeners(); 228 229 // In principle, a listener could be added to the 230 // pushBroadcastService here, after we have gotten listeners and 231 // before we're RUNNING, but this can't happen in practice because 232 // the only caller that can add listeners is PushBroadcastService, 233 // and it waits on the same promise we are before it can add 234 // listeners. If PushBroadcastService gets woken first, it will 235 // update the value that is eventually returned from 236 // getListeners. 237 this._setState(PUSH_SERVICE_RUNNING); 238 239 this._service.connect(broadcastListeners); 240 }, 241 242 _changeStateConnectionEnabledEvent(enabled) { 243 lazy.console.debug("changeStateConnectionEnabledEvent()", enabled); 244 245 if ( 246 this._state < PUSH_SERVICE_CONNECTION_DISABLE && 247 this._state != PUSH_SERVICE_ACTIVATING 248 ) { 249 return Promise.resolve(); 250 } 251 252 if (enabled) { 253 return this._changeStateOfflineEvent(Services.io.offline, true); 254 } 255 256 if (this._state == PUSH_SERVICE_RUNNING) { 257 this._service.disconnect(); 258 } 259 this._setState(PUSH_SERVICE_CONNECTION_DISABLE); 260 return Promise.resolve(); 261 }, 262 263 // Used for testing. 264 changeTestServer(url, options = {}) { 265 lazy.console.debug("changeTestServer()"); 266 267 return this._stateChangeProcessEnqueue(_ => { 268 if (this._state < PUSH_SERVICE_ACTIVATING) { 269 lazy.console.debug("changeTestServer: PushService not activated?"); 270 return Promise.resolve(); 271 } 272 273 return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options); 274 }); 275 }, 276 277 observe: function observe(aSubject, aTopic, aData) { 278 switch (aTopic) { 279 /* 280 * We need to call uninit() on shutdown to clean up things that modules 281 * aren't very good at automatically cleaning up, so we don't get shutdown 282 * leaks on browser shutdown. 283 */ 284 case "quit-application": 285 this.uninit(); 286 break; 287 case "network:offline-status-changed": 288 this._stateChangeProcessEnqueue(_ => 289 this._changeStateOfflineEvent(aData === "offline", false) 290 ); 291 break; 292 293 case "nsPref:changed": 294 if (aData == "serverURL") { 295 lazy.console.debug( 296 "observe: dom.push.serverURL changed for websocket", 297 prefs.getStringPref("serverURL") 298 ); 299 this._stateChangeProcessEnqueue(_ => 300 this._changeServerURL( 301 prefs.getStringPref("serverURL"), 302 CHANGING_SERVICE_EVENT 303 ) 304 ); 305 } else if (aData == "connection.enabled") { 306 this._stateChangeProcessEnqueue(_ => 307 this._changeStateConnectionEnabledEvent( 308 prefs.getBoolPref("connection.enabled") 309 ) 310 ); 311 } 312 break; 313 314 case "idle-daily": 315 this._dropExpiredRegistrations().catch(error => { 316 lazy.console.error( 317 "Failed to drop expired registrations on idle", 318 error 319 ); 320 }); 321 break; 322 323 case "perm-changed": 324 this._onPermissionChange(aSubject, aData).catch(error => { 325 lazy.console.error( 326 "onPermissionChange: Error updating registrations:", 327 error 328 ); 329 }); 330 break; 331 332 case "clear-origin-attributes-data": 333 this._clearOriginData(aData).catch(error => { 334 lazy.console.error( 335 "clearOriginData: Error clearing origin data:", 336 error 337 ); 338 }); 339 break; 340 } 341 }, 342 343 _clearOriginData(data) { 344 lazy.console.log("clearOriginData()"); 345 346 if (!data) { 347 return Promise.resolve(); 348 } 349 350 let pattern = JSON.parse(data); 351 return this._dropRegistrationsIf(record => 352 record.matchesOriginAttributes(pattern) 353 ); 354 }, 355 356 /** 357 * Sends an unregister request to the server in the background. If the 358 * service is not connected, this function is a no-op. 359 * 360 * @param {PushRecord} record The record to unregister. 361 * @param {number} reason An `nsIPushErrorReporter` unsubscribe reason, 362 * indicating why this record was removed. 363 */ 364 _backgroundUnregister(record, reason) { 365 lazy.console.debug("backgroundUnregister()"); 366 367 if (!this._service.isConnected() || !record) { 368 return; 369 } 370 371 lazy.console.debug("backgroundUnregister: Notifying server", record); 372 this._sendUnregister(record, reason) 373 .then(() => { 374 lazy.gPushNotifier.notifySubscriptionModified( 375 record.scope, 376 record.principal 377 ); 378 }) 379 .catch(e => { 380 lazy.console.error("backgroundUnregister: Error notifying server", e); 381 }); 382 }, 383 384 _findService(serverURL) { 385 lazy.console.debug("findService()"); 386 387 if (!serverURL) { 388 lazy.console.warn("findService: No dom.push.serverURL found"); 389 return []; 390 } 391 392 let uri; 393 try { 394 uri = Services.io.newURI(serverURL); 395 } catch (e) { 396 lazy.console.warn( 397 "findService: Error creating valid URI from", 398 "dom.push.serverURL", 399 serverURL 400 ); 401 return []; 402 } 403 404 let service = getServiceForServerURI(uri); 405 return [service, uri]; 406 }, 407 408 _changeServerURL(serverURI, event, options = {}) { 409 lazy.console.debug("changeServerURL()"); 410 411 switch (event) { 412 case UNINIT_EVENT: 413 return this._stopService(event); 414 415 case STARTING_SERVICE_EVENT: { 416 let [service, uri] = this._findService(serverURI); 417 if (!service) { 418 this._setState(PUSH_SERVICE_INIT); 419 return Promise.resolve(); 420 } 421 return this._startService(service, uri, options).then(_ => 422 this._changeStateConnectionEnabledEvent( 423 prefs.getBoolPref("connection.enabled") 424 ) 425 ); 426 } 427 case CHANGING_SERVICE_EVENT: { 428 let [service, uri] = this._findService(serverURI); 429 if (service) { 430 if (this._state == PUSH_SERVICE_INIT) { 431 this._setState(PUSH_SERVICE_ACTIVATING); 432 // The service has not been running - start it. 433 return this._startService(service, uri, options).then(_ => 434 this._changeStateConnectionEnabledEvent( 435 prefs.getBoolPref("connection.enabled") 436 ) 437 ); 438 } 439 this._setState(PUSH_SERVICE_ACTIVATING); 440 // If we already had running service - stop service, start the new 441 // one and check connection.enabled and offline state(offline state 442 // check is called in changeStateConnectionEnabledEvent function) 443 return this._stopService(CHANGING_SERVICE_EVENT) 444 .then(_ => this._startService(service, uri, options)) 445 .then(_ => 446 this._changeStateConnectionEnabledEvent( 447 prefs.getBoolPref("connection.enabled") 448 ) 449 ); 450 } 451 if (this._state == PUSH_SERVICE_INIT) { 452 return Promise.resolve(); 453 } 454 // The new serverUri is empty or misconfigured - stop service. 455 this._setState(PUSH_SERVICE_INIT); 456 return this._stopService(STOPPING_SERVICE_EVENT); 457 } 458 default: 459 lazy.console.error("Unexpected event in _changeServerURL", event); 460 return Promise.reject(new Error(`Unexpected event ${event}`)); 461 } 462 }, 463 464 /** 465 * PushService initialization is divided into 4 parts: 466 * init() - start listening for quit-application and serverURL changes. 467 * state is change to PUSH_SERVICE_INIT 468 * startService() - if serverURL is present this function is called. It starts 469 * listening for broadcasted messages, starts db and 470 * PushService connection (WebSocket). 471 * state is change to PUSH_SERVICE_ACTIVATING. 472 * startObservers() - start other observers. 473 * changeStateConnectionEnabledEvent - checks prefs and offline state. 474 * It changes state to: 475 * PUSH_SERVICE_RUNNING, 476 * PUSH_SERVICE_ACTIVE_OFFLINE or 477 * PUSH_SERVICE_CONNECTION_DISABLE. 478 */ 479 async init(options = {}) { 480 lazy.console.debug("init()"); 481 482 if (this._state > PUSH_SERVICE_UNINIT) { 483 return; 484 } 485 486 this._setState(PUSH_SERVICE_ACTIVATING); 487 488 prefs.addObserver("serverURL", this); 489 Services.obs.addObserver(this, "quit-application"); 490 491 if (options.serverURI) { 492 // this is use for xpcshell test. 493 494 await this._stateChangeProcessEnqueue(_ => 495 this._changeServerURL( 496 options.serverURI, 497 STARTING_SERVICE_EVENT, 498 options 499 ) 500 ); 501 } else { 502 // This is only used for testing. Different tests require connecting to 503 // slightly different URLs. 504 await this._stateChangeProcessEnqueue(_ => 505 this._changeServerURL( 506 prefs.getStringPref("serverURL"), 507 STARTING_SERVICE_EVENT 508 ) 509 ); 510 } 511 }, 512 513 _startObservers() { 514 lazy.console.debug("startObservers()"); 515 516 if (this._state != PUSH_SERVICE_ACTIVATING) { 517 return; 518 } 519 520 Services.obs.addObserver(this, "clear-origin-attributes-data"); 521 522 // The offline-status-changed event is used to know 523 // when to (dis)connect. It may not fire if the underlying OS changes 524 // networks; in such a case we rely on timeout. 525 Services.obs.addObserver(this, "network:offline-status-changed"); 526 527 // Used to monitor if the user wishes to disable Push. 528 prefs.addObserver("connection.enabled", this); 529 530 // Prunes expired registrations and notifies dormant service workers. 531 Services.obs.addObserver(this, "idle-daily"); 532 533 // Prunes registrations for sites for which the user revokes push 534 // permissions. 535 Services.obs.addObserver(this, "perm-changed"); 536 }, 537 538 _startService(service, serverURI, options) { 539 lazy.console.debug("startService()"); 540 541 if (this._state != PUSH_SERVICE_ACTIVATING) { 542 return Promise.reject(); 543 } 544 545 this._service = service; 546 547 this._db = options.db; 548 if (!this._db) { 549 this._db = this._service.newPushDB(); 550 } 551 552 return this._service.init(options, this, serverURI).then(() => { 553 this._startObservers(); 554 return this._dropExpiredRegistrations(); 555 }); 556 }, 557 558 /** 559 * PushService uninitialization is divided into 3 parts: 560 * stopObservers() - stot observers started in startObservers. 561 * stopService() - It stops listening for broadcasted messages, stops db and 562 * PushService connection (WebSocket). 563 * state is changed to PUSH_SERVICE_INIT. 564 * uninit() - stop listening for quit-application and serverURL changes. 565 * state is change to PUSH_SERVICE_UNINIT 566 */ 567 _stopService(event) { 568 lazy.console.debug("stopService()"); 569 570 if (this._state < PUSH_SERVICE_ACTIVATING) { 571 return Promise.resolve(); 572 } 573 574 this._stopObservers(); 575 576 this._service.disconnect(); 577 this._service.uninit(); 578 this._service = null; 579 580 this._updateQuotaTimeouts.forEach(timeoutID => clearTimeout(timeoutID)); 581 this._updateQuotaTimeouts.clear(); 582 583 if (!this._db) { 584 return Promise.resolve(); 585 } 586 if (event == UNINIT_EVENT) { 587 // If it is uninitialized just close db. 588 this._db.close(); 589 this._db = null; 590 return Promise.resolve(); 591 } 592 593 return this.dropUnexpiredRegistrations().then( 594 _ => { 595 this._db.close(); 596 this._db = null; 597 }, 598 () => { 599 this._db.close(); 600 this._db = null; 601 } 602 ); 603 }, 604 605 _stopObservers() { 606 lazy.console.debug("stopObservers()"); 607 608 if (this._state < PUSH_SERVICE_ACTIVATING) { 609 return; 610 } 611 612 prefs.removeObserver("connection.enabled", this); 613 614 Services.obs.removeObserver(this, "network:offline-status-changed"); 615 Services.obs.removeObserver(this, "clear-origin-attributes-data"); 616 Services.obs.removeObserver(this, "idle-daily"); 617 Services.obs.removeObserver(this, "perm-changed"); 618 }, 619 620 _shutdownService() { 621 let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT); 622 this._setState(PUSH_SERVICE_UNINIT); 623 lazy.console.debug("shutdownService: shutdown complete!"); 624 return promiseChangeURL; 625 }, 626 627 async uninit() { 628 lazy.console.debug("uninit()"); 629 630 if (this._state == PUSH_SERVICE_UNINIT) { 631 return; 632 } 633 634 prefs.removeObserver("serverURL", this); 635 Services.obs.removeObserver(this, "quit-application"); 636 637 await this._stateChangeProcessEnqueue(_ => this._shutdownService()); 638 }, 639 640 /** 641 * Drops all active registrations and notifies the associated service 642 * workers. This function is called when the user switches Push servers, 643 * or when the server invalidates all existing registrations. 644 * 645 * We ignore expired registrations because they're already handled in other 646 * code paths. Registrations that expired after exceeding their quotas are 647 * evicted at startup, or on the next `idle-daily` event. Registrations that 648 * expired because the user revoked the notification permission are evicted 649 * once the permission is reinstated. 650 */ 651 dropUnexpiredRegistrations() { 652 return this._db.clearIf(record => { 653 if (record.isExpired()) { 654 return false; 655 } 656 this._notifySubscriptionChangeObservers(record); 657 return true; 658 }); 659 }, 660 661 _notifySubscriptionChangeObservers(record) { 662 if (!record) { 663 return; 664 } 665 lazy.gPushNotifier.notifySubscriptionChange( 666 record.scope, 667 record.principal, 668 new ChromePushSubscription(record.toSubscription()) 669 ); 670 }, 671 672 /** 673 * Drops a registration and notifies the associated service worker. If the 674 * registration does not exist, this function is a no-op. 675 * 676 * @param {string} keyID The registration ID to remove. 677 * @returns {Promise} Resolves once the worker has been notified. 678 */ 679 dropRegistrationAndNotifyApp(aKeyID) { 680 return this._db 681 .delete(aKeyID) 682 .then(record => this._notifySubscriptionChangeObservers(record)); 683 }, 684 685 /** 686 * Updates a registration and notifies the associated service worker. 687 * 688 * @param {string} keyID The registration ID to update. 689 * @param {Function} updateFunc Returns the updated record. 690 * @returns {Promise} Resolves with the updated record once the worker 691 * has been notified. 692 */ 693 updateRecordAndNotifyApp(aKeyID, aUpdateFunc) { 694 return this._db.update(aKeyID, aUpdateFunc).then(record => { 695 this._notifySubscriptionChangeObservers(record); 696 return record; 697 }); 698 }, 699 700 ensureCrypto(record) { 701 if ( 702 record.hasAuthenticationSecret() && 703 record.p256dhPublicKey && 704 record.p256dhPrivateKey 705 ) { 706 return Promise.resolve(record); 707 } 708 709 let keygen = Promise.resolve([]); 710 if (!record.p256dhPublicKey || !record.p256dhPrivateKey) { 711 keygen = lazy.PushCrypto.generateKeys(); 712 } 713 // We do not have a encryption key. so we need to generate it. This 714 // is only going to happen on db upgrade from version 4 to higher. 715 return keygen.then( 716 ([pubKey, privKey]) => { 717 return this.updateRecordAndNotifyApp(record.keyID, recordToUpdate => { 718 if ( 719 !recordToUpdate.p256dhPublicKey || 720 !recordToUpdate.p256dhPrivateKey 721 ) { 722 recordToUpdate.p256dhPublicKey = pubKey; 723 recordToUpdate.p256dhPrivateKey = privKey; 724 } 725 if (!recordToUpdate.hasAuthenticationSecret()) { 726 recordToUpdate.authenticationSecret = 727 lazy.PushCrypto.generateAuthenticationSecret(); 728 } 729 return recordToUpdate; 730 }); 731 }, 732 error => { 733 return this.dropRegistrationAndNotifyApp(record.keyID).then(() => 734 Promise.reject(error) 735 ); 736 } 737 ); 738 }, 739 740 /** 741 * Dispatches an incoming message to a service worker, recalculating the 742 * quota for the associated push registration. If the quota is exceeded, 743 * the registration and message will be dropped, and the worker will not 744 * be notified. 745 * 746 * @param {string} keyID The push registration ID. 747 * @param {string} messageID The message ID, used to report service worker 748 * delivery failures. For Web Push messages, this is the version. If empty, 749 * failures will not be reported. 750 * @param {object} headers The encryption headers. 751 * @param {ArrayBuffer|Uint8Array} data The encrypted message data. 752 * @param {Function} updateFunc A function that receives the existing 753 * registration record as its argument, and returns a new record. If the 754 * function returns `null` or `undefined`, the record will not be updated. 755 * `PushServiceWebSocket` uses this to drop incoming updates with older 756 * versions. 757 * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status 758 * code, indicating whether the message was delivered successfully. 759 */ 760 receivedPushMessage(keyID, messageID, headers, data, updateFunc) { 761 lazy.console.debug("receivedPushMessage()"); 762 763 return this._updateRecordAfterPush(keyID, updateFunc) 764 .then(record => { 765 if (record.quotaApplies()) { 766 // Update quota after the delay, at which point 767 // we check for visible notifications. 768 let timeoutID = setTimeout(_ => { 769 this._updateQuota(keyID); 770 if (!this._updateQuotaTimeouts.delete(timeoutID)) { 771 lazy.console.debug( 772 "receivedPushMessage: quota update timeout missing?" 773 ); 774 } 775 }, prefs.getIntPref("quotaUpdateDelay")); 776 this._updateQuotaTimeouts.add(timeoutID); 777 } 778 return this._decryptAndNotifyApp(record, messageID, headers, data); 779 }) 780 .catch(error => { 781 lazy.console.error("receivedPushMessage: Error notifying app", error); 782 return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; 783 }); 784 }, 785 786 /** 787 * Dispatches a broadcast notification to the BroadcastService. 788 * 789 * @param {object} message The reply received by PushServiceWebSocket 790 * @param {object} context Additional information about the context in which the 791 * notification was received. 792 */ 793 receivedBroadcastMessage(message, context) { 794 lazy.pushBroadcastService 795 .receivedBroadcastMessage(message.broadcasts, context) 796 .catch(e => { 797 lazy.console.error(e); 798 }); 799 }, 800 801 /** 802 * Updates a registration record after receiving a push message. 803 * 804 * @param {string} keyID The push registration ID. 805 * @param {Function} updateFunc The function passed to `receivedPushMessage`. 806 * @returns {Promise} Resolves with the updated record, or rejects if the 807 * record was not updated. 808 */ 809 _updateRecordAfterPush(keyID, updateFunc) { 810 return this.getByKeyID(keyID) 811 .then(record => { 812 if (!record) { 813 throw new Error("No record for key ID " + keyID); 814 } 815 return record 816 .getLastVisit() 817 .then(lastVisit => { 818 // As a special case, don't notify the service worker if the user 819 // cleared their history. 820 if (!isFinite(lastVisit)) { 821 throw new Error("Ignoring message sent to unvisited origin"); 822 } 823 return lastVisit; 824 }) 825 .then(lastVisit => { 826 // Update the record, resetting the quota if the user has visited the 827 // site since the last push. 828 return this._db.update(keyID, recordToUpdate => { 829 let newRecord = updateFunc(recordToUpdate); 830 if (!newRecord) { 831 return null; 832 } 833 // Because `unregister` is advisory only, we can still receive messages 834 // for stale Simple Push registrations from the server. To work around 835 // this, we check if the record has expired before *and* after updating 836 // the quota. 837 if (newRecord.isExpired()) { 838 return null; 839 } 840 newRecord.receivedPush(lastVisit); 841 return newRecord; 842 }); 843 }); 844 }) 845 .then(record => { 846 lazy.gPushNotifier.notifySubscriptionModified( 847 record.scope, 848 record.principal 849 ); 850 return record; 851 }); 852 }, 853 854 /** 855 * Decrypts an incoming message and notifies the associated service worker. 856 * 857 * @param {PushRecord} record The receiving registration. 858 * @param {string} messageID The message ID. 859 * @param {object} headers The encryption headers. 860 * @param {ArrayBuffer|Uint8Array} data The encrypted message data. 861 * @returns {Promise} Resolves with an ack status code. 862 */ 863 _decryptAndNotifyApp(record, messageID, headers, data) { 864 return lazy.PushCrypto.decrypt( 865 record.p256dhPrivateKey, 866 record.p256dhPublicKey, 867 record.authenticationSecret, 868 headers, 869 data 870 ).then( 871 message => this._notifyApp(record, messageID, message), 872 error => { 873 lazy.console.warn( 874 "decryptAndNotifyApp: Error decrypting message", 875 record.scope, 876 messageID, 877 error 878 ); 879 880 let message = error.format(record.scope); 881 lazy.gPushNotifier.notifyError( 882 record.scope, 883 record.principal, 884 message, 885 Ci.nsIScriptError.errorFlag 886 ); 887 return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR; 888 } 889 ); 890 }, 891 892 _updateQuota(keyID) { 893 lazy.console.debug("updateQuota()"); 894 895 this._db 896 .update(keyID, record => { 897 // Record may have expired from an earlier quota update. 898 if (record.isExpired()) { 899 lazy.console.debug( 900 "updateQuota: Trying to update quota for expired record", 901 record 902 ); 903 return null; 904 } 905 // If there are visible notifications, don't apply the quota penalty 906 // for the message. 907 if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) { 908 record.reduceQuota(); 909 } 910 return record; 911 }) 912 .then(record => { 913 if (record.isExpired()) { 914 // Drop the registration in the background. If the user returns to the 915 // site, the service worker will be notified on the next `idle-daily` 916 // event. 917 this._backgroundUnregister( 918 record, 919 Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED 920 ); 921 } else { 922 lazy.gPushNotifier.notifySubscriptionModified( 923 record.scope, 924 record.principal 925 ); 926 } 927 if (this._updateQuotaTestCallback) { 928 // Callback so that test may be notified when the quota update is complete. 929 this._updateQuotaTestCallback(); 930 } 931 }) 932 .catch(error => { 933 lazy.console.debug( 934 "updateQuota: Error while trying to update quota", 935 error 936 ); 937 }); 938 }, 939 940 notificationForOriginShown(origin) { 941 lazy.console.debug("notificationForOriginShown()", origin); 942 let count; 943 if (this._visibleNotifications.has(origin)) { 944 count = this._visibleNotifications.get(origin); 945 } else { 946 count = 0; 947 } 948 this._visibleNotifications.set(origin, count + 1); 949 }, 950 951 notificationForOriginClosed(origin) { 952 lazy.console.debug("notificationForOriginClosed()", origin); 953 let count; 954 if (this._visibleNotifications.has(origin)) { 955 count = this._visibleNotifications.get(origin); 956 } else { 957 lazy.console.debug( 958 "notificationForOriginClosed: closing notification that has not been shown?" 959 ); 960 return; 961 } 962 if (count > 1) { 963 this._visibleNotifications.set(origin, count - 1); 964 } else { 965 this._visibleNotifications.delete(origin); 966 } 967 }, 968 969 reportDeliveryError(messageID, reason) { 970 lazy.console.debug("reportDeliveryError()", messageID, reason); 971 if (this._state == PUSH_SERVICE_RUNNING && this._service.isConnected()) { 972 // Only report errors if we're initialized and connected. 973 this._service.reportDeliveryError(messageID, reason); 974 } 975 }, 976 977 _notifyApp(aPushRecord, messageID, message) { 978 if ( 979 !aPushRecord || 980 !aPushRecord.scope || 981 aPushRecord.originAttributes === undefined 982 ) { 983 lazy.console.error("notifyApp: Invalid record", aPushRecord); 984 return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; 985 } 986 987 lazy.console.debug("notifyApp()", aPushRecord.scope); 988 989 // If permission has been revoked, trash the message. 990 if (!aPushRecord.hasPermission()) { 991 lazy.console.warn("notifyApp: Missing push permission", aPushRecord); 992 return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED; 993 } 994 995 let payload = ArrayBuffer.isView(message) 996 ? new Uint8Array(message.buffer) 997 : message; 998 999 if (aPushRecord.quotaApplies()) { 1000 // Don't record telemetry for chrome push messages. 1001 Glean.webPush.apiNotify.add(1); 1002 } 1003 1004 if (payload) { 1005 lazy.gPushNotifier.notifyPushWithData( 1006 aPushRecord.scope, 1007 aPushRecord.principal, 1008 messageID, 1009 payload 1010 ); 1011 } else { 1012 lazy.gPushNotifier.notifyPush( 1013 aPushRecord.scope, 1014 aPushRecord.principal, 1015 messageID 1016 ); 1017 } 1018 1019 return Ci.nsIPushErrorReporter.ACK_DELIVERED; 1020 }, 1021 1022 getByKeyID(aKeyID) { 1023 return this._db.getByKeyID(aKeyID); 1024 }, 1025 1026 getAllUnexpired() { 1027 return this._db.getAllUnexpired(); 1028 }, 1029 1030 _sendRequest(action, ...params) { 1031 if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) { 1032 return Promise.reject(new Error("Push service disabled")); 1033 } 1034 if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) { 1035 return Promise.reject(new Error("Push service offline")); 1036 } 1037 // Ensure the backend is ready. `getByPageRecord` already checks this, but 1038 // we need to check again here in case the service was restarted in the 1039 // meantime. 1040 return this._checkActivated().then(_ => { 1041 switch (action) { 1042 case "register": 1043 return this._service.register(...params); 1044 case "unregister": 1045 return this._service.unregister(...params); 1046 } 1047 return Promise.reject(new Error("Unknown request type: " + action)); 1048 }); 1049 }, 1050 1051 /** 1052 * Called on message from the child process. aPageRecord is an object sent by 1053 * the push manager, identifying the sending page and other fields. 1054 */ 1055 _registerWithServer(aPageRecord) { 1056 lazy.console.debug("registerWithServer()", aPageRecord); 1057 1058 return this._sendRequest("register", aPageRecord) 1059 .then( 1060 record => this._onRegisterSuccess(record), 1061 err => this._onRegisterError(err) 1062 ) 1063 .then( 1064 record => { 1065 this._deletePendingRequest(aPageRecord); 1066 lazy.gPushNotifier.notifySubscriptionModified( 1067 record.scope, 1068 record.principal 1069 ); 1070 return record.toSubscription(); 1071 }, 1072 err => { 1073 this._deletePendingRequest(aPageRecord); 1074 throw err; 1075 } 1076 ); 1077 }, 1078 1079 _sendUnregister(aRecord, aReason) { 1080 return this._sendRequest("unregister", aRecord, aReason); 1081 }, 1082 1083 /** 1084 * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained 1085 * from _service.request, causing the promise to be rejected instead. 1086 */ 1087 _onRegisterSuccess(aRecord) { 1088 lazy.console.debug("_onRegisterSuccess()"); 1089 1090 return this._db.put(aRecord).catch(error => { 1091 // Unable to save. Destroy the subscription in the background. 1092 this._backgroundUnregister( 1093 aRecord, 1094 Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL 1095 ); 1096 throw error; 1097 }); 1098 }, 1099 1100 /** 1101 * Exceptions thrown in _onRegisterError are caught by the promise obtained 1102 * from _service.request, causing the promise to be rejected instead. 1103 */ 1104 _onRegisterError(reply) { 1105 lazy.console.debug("_onRegisterError()"); 1106 1107 if (!reply.error) { 1108 lazy.console.warn( 1109 "onRegisterError: Called without valid error message!", 1110 reply 1111 ); 1112 throw new Error("Registration error"); 1113 } 1114 throw reply.error; 1115 }, 1116 1117 notificationsCleared() { 1118 this._visibleNotifications.clear(); 1119 }, 1120 1121 _getByPageRecord(pageRecord) { 1122 return this._checkActivated().then(_ => 1123 this._db.getByIdentifiers(pageRecord) 1124 ); 1125 }, 1126 1127 register(aPageRecord) { 1128 lazy.console.debug("register()", aPageRecord); 1129 1130 let keyPromise; 1131 if (aPageRecord.appServerKey && aPageRecord.appServerKey.length) { 1132 let keyView = new Uint8Array(aPageRecord.appServerKey); 1133 keyPromise = lazy.PushCrypto.validateAppServerKey(keyView).catch(() => { 1134 // Normalize Web Crypto exceptions. `nsIPushService` will forward the 1135 // error result to the DOM API implementation in `PushManager.cpp` or 1136 // `Push.js`, which will convert it to the correct `DOMException`. 1137 throw errorWithResult( 1138 "Invalid app server key", 1139 Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR 1140 ); 1141 }); 1142 } else { 1143 keyPromise = Promise.resolve(null); 1144 } 1145 1146 return Promise.all([keyPromise, this._getByPageRecord(aPageRecord)]).then( 1147 ([appServerKey, record]) => { 1148 aPageRecord.appServerKey = appServerKey; 1149 if (!record) { 1150 return this._lookupOrPutPendingRequest(aPageRecord); 1151 } 1152 if (!record.matchesAppServerKey(appServerKey)) { 1153 throw errorWithResult( 1154 "Mismatched app server key", 1155 Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR 1156 ); 1157 } 1158 if (record.isExpired()) { 1159 return record 1160 .quotaChanged() 1161 .then(isChanged => { 1162 if (isChanged) { 1163 // If the user revisited the site, drop the expired push 1164 // registration and re-register. 1165 return this.dropRegistrationAndNotifyApp(record.keyID); 1166 } 1167 throw new Error("Push subscription expired"); 1168 }) 1169 .then(_ => this._lookupOrPutPendingRequest(aPageRecord)); 1170 } 1171 return record.toSubscription(); 1172 } 1173 ); 1174 }, 1175 1176 /* 1177 * Called only by the PushBroadcastService on the receipt of a new 1178 * subscription. Don't call this directly. Go through PushBroadcastService. 1179 */ 1180 async subscribeBroadcast(broadcastId, version) { 1181 if (this._state != PUSH_SERVICE_RUNNING) { 1182 // Ignore any request to subscribe before we send a hello. 1183 // We'll send all the broadcast listeners as part of the hello 1184 // anyhow. 1185 return; 1186 } 1187 1188 await this._service.sendSubscribeBroadcast(broadcastId, version); 1189 }, 1190 1191 /** 1192 * Called on message from the child process. 1193 * 1194 * Why is the record being deleted from the local database before the server 1195 * is told? 1196 * 1197 * Unregistration is for the benefit of the app and the AppServer 1198 * so that the AppServer does not keep pinging a channel the UserAgent isn't 1199 * watching The important part of the transaction in this case is left to the 1200 * app, to tell its server of the unregistration. Even if the request to the 1201 * PushServer were to fail, it would not affect correctness of the protocol, 1202 * and the server GC would just clean up the channelID/subscription 1203 * eventually. Since the appserver doesn't ping it, no data is lost. 1204 * 1205 * If rather we were to unregister at the server and update the database only 1206 * on success: If the server receives the unregister, and deletes the 1207 * channelID/subscription, but the response is lost because of network 1208 * failure, the application is never informed. In addition the application may 1209 * retry the unregister when it fails due to timeout (websocket) or any other 1210 * reason at which point the server will say it does not know of this 1211 * unregistration. We'll have to make the registration/unregistration phases 1212 * have retries and attempts to resend messages from the server, and have the 1213 * client acknowledge. On a server, data is cheap, reliable notification is 1214 * not. 1215 */ 1216 unregister(aPageRecord) { 1217 lazy.console.debug("unregister()", aPageRecord); 1218 1219 return this._getByPageRecord(aPageRecord).then(record => { 1220 if (record === null) { 1221 return false; 1222 } 1223 1224 let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL; 1225 return Promise.all([ 1226 this._sendUnregister(record, reason), 1227 this._db.delete(record.keyID).then(rec => { 1228 if (rec) { 1229 lazy.gPushNotifier.notifySubscriptionModified( 1230 rec.scope, 1231 rec.principal 1232 ); 1233 } 1234 }), 1235 ]).then(([success]) => success); 1236 }); 1237 }, 1238 1239 /** 1240 * Clear subscriptions matching either a principal or a domain and 1241 * OriginAttributesPattern. If domain="*" is passed all records will be 1242 * deleted. 1243 * 1244 * @param {*} options 1245 * @param {nsIPrincipal} [options.principal] - The principal to clear 1246 * subscriptions for. This does an exact origin match. 1247 * @param {string} [options.domain] - Clear all records matching the domain, 1248 * including subdomains. 1249 * @param {OriginAttributesPattern} [options.originAttributesPattern] - 1250 * Additional OriginAttributes filter for clearing by domain. Ignored for 1251 * domain == "*". 1252 * @returns {Promise} - A Promise which resolves once the operation has 1253 * completed. 1254 */ 1255 clear({ principal, domain, originAttributesPattern }) { 1256 return this._checkActivated() 1257 .then(_ => { 1258 return this._dropRegistrationsIf(record => { 1259 // Drop all 1260 if (domain == "*") { 1261 return true; 1262 } 1263 // Can't match against record if it doesn't have a URI. 1264 if (record.uri == null) { 1265 return false; 1266 } 1267 1268 let originAttributes; 1269 if (principal || originAttributesPattern) { 1270 // Restore OriginAttributes from record.originAttributes which 1271 // contains the OA suffix string. We need this to match against the 1272 // principal or the OriginAttributesPattern. 1273 try { 1274 originAttributes = 1275 ChromeUtils.CreateOriginAttributesFromOriginSuffix( 1276 record.originAttributes 1277 ); 1278 } catch (e) { 1279 console.warn("Error while parsing record OA suffix.", e); 1280 return false; 1281 } 1282 } 1283 1284 // Drop all matching principal. 1285 if (principal) { 1286 // Build a principal from the record metadata so we can compare it 1287 // to the given principal. 1288 let recordPrincipal = 1289 Services.scriptSecurityManager.createContentPrincipal( 1290 record.uri, 1291 originAttributes 1292 ); 1293 return recordPrincipal.equals(principal); 1294 } 1295 1296 if (!record.uri.host) { 1297 return false; 1298 } 1299 1300 // Drop all matching domain + OA pattern. 1301 return Services.clearData.hostMatchesSite( 1302 record.uri.host, 1303 originAttributes, 1304 domain, 1305 originAttributesPattern 1306 ); 1307 }); 1308 }) 1309 .catch(e => { 1310 lazy.console.warn( 1311 "clear: Error dropping subscriptions for domain or principal", 1312 domain, 1313 e 1314 ); 1315 return Promise.resolve(); 1316 }); 1317 }, 1318 1319 registration(aPageRecord) { 1320 lazy.console.debug("registration()"); 1321 1322 return this._getByPageRecord(aPageRecord).then(record => { 1323 if (!record) { 1324 return null; 1325 } 1326 if (record.isExpired()) { 1327 return record.quotaChanged().then(isChanged => { 1328 if (isChanged) { 1329 return this.dropRegistrationAndNotifyApp(record.keyID).then( 1330 _ => null 1331 ); 1332 } 1333 return null; 1334 }); 1335 } 1336 return record.toSubscription(); 1337 }); 1338 }, 1339 1340 _dropExpiredRegistrations() { 1341 lazy.console.debug("dropExpiredRegistrations()"); 1342 1343 return this._db.getAllExpired().then(records => { 1344 return Promise.all( 1345 records.map(record => 1346 record 1347 .quotaChanged() 1348 .then(isChanged => { 1349 if (isChanged) { 1350 // If the user revisited the site, drop the expired push 1351 // registration and notify the associated service worker. 1352 this.dropRegistrationAndNotifyApp(record.keyID); 1353 } 1354 }) 1355 .catch(error => { 1356 lazy.console.error( 1357 "dropExpiredRegistrations: Error dropping registration", 1358 record.keyID, 1359 error 1360 ); 1361 }) 1362 ) 1363 ); 1364 }); 1365 }, 1366 1367 _onPermissionChange(subject, data) { 1368 lazy.console.debug("onPermissionChange()"); 1369 1370 if (data == "cleared") { 1371 return this._clearPermissions(); 1372 } 1373 1374 let permission = subject.QueryInterface(Ci.nsIPermission); 1375 if (permission.type != "desktop-notification") { 1376 return Promise.resolve(); 1377 } 1378 1379 return this._updatePermission(permission, data); 1380 }, 1381 1382 _clearPermissions() { 1383 lazy.console.debug("clearPermissions()"); 1384 1385 return this._db.clearIf(record => { 1386 if (!record.quotaApplies()) { 1387 // Only drop registrations that are subject to quota. 1388 return false; 1389 } 1390 this._backgroundUnregister( 1391 record, 1392 Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED 1393 ); 1394 return true; 1395 }); 1396 }, 1397 1398 _updatePermission(permission, type) { 1399 lazy.console.debug("updatePermission()"); 1400 1401 let isAllow = permission.capability == Ci.nsIPermissionManager.ALLOW_ACTION; 1402 let isChange = type == "added" || type == "changed"; 1403 1404 if (isAllow && isChange) { 1405 // Permission set to "allow". Drop all expired registrations for this 1406 // site, notify the associated service workers, and reset the quota 1407 // for active registrations. 1408 return this._forEachPrincipal(permission.principal, (record, cursor) => 1409 this._permissionAllowed(record, cursor) 1410 ); 1411 } else if (isChange || (isAllow && type == "deleted")) { 1412 // Permission set to "block" or "always ask," or "allow" permission 1413 // removed. Expire all registrations for this site. 1414 return this._forEachPrincipal(permission.principal, (record, cursor) => 1415 this._permissionDenied(record, cursor) 1416 ); 1417 } 1418 1419 return Promise.resolve(); 1420 }, 1421 1422 _forEachPrincipal(principal, callback) { 1423 return this._db.forEachOrigin( 1424 principal.URI.prePath, 1425 ChromeUtils.originAttributesToSuffix(principal.originAttributes), 1426 callback 1427 ); 1428 }, 1429 1430 /** 1431 * The update function called for each registration record if the push 1432 * permission is revoked. We only expire the record so we can notify the 1433 * service worker as soon as the permission is reinstated. If we just 1434 * deleted the record, the worker wouldn't be notified until the next visit 1435 * to the site. 1436 * 1437 * @param {PushRecord} record The record to expire. 1438 * @param {IDBCursor} cursor The IndexedDB cursor. 1439 */ 1440 _permissionDenied(record, cursor) { 1441 lazy.console.debug("permissionDenied()"); 1442 1443 if (!record.quotaApplies() || record.isExpired()) { 1444 // Ignore already-expired records. 1445 return; 1446 } 1447 // Drop the registration in the background. 1448 this._backgroundUnregister( 1449 record, 1450 Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED 1451 ); 1452 record.setQuota(0); 1453 cursor.update(record); 1454 }, 1455 1456 /** 1457 * The update function called for each registration record if the push 1458 * permission is granted. If the record has expired, it will be dropped; 1459 * otherwise, its quota will be reset to the default value. 1460 * 1461 * @param {PushRecord} record The record to update. 1462 * @param {IDBCursor} cursor The IndexedDB cursor. 1463 */ 1464 _permissionAllowed(record, cursor) { 1465 lazy.console.debug("permissionAllowed()"); 1466 1467 if (!record.quotaApplies()) { 1468 return; 1469 } 1470 if (record.isExpired()) { 1471 // If the registration has expired, drop and notify the worker 1472 // unconditionally. 1473 this._notifySubscriptionChangeObservers(record); 1474 cursor.delete(); 1475 return; 1476 } 1477 record.resetQuota(); 1478 cursor.update(record); 1479 }, 1480 1481 /** 1482 * Drops all matching registrations from the database. Notifies the 1483 * associated service workers if permission is granted, and removes 1484 * unexpired registrations from the server. 1485 * 1486 * @param {Function} predicate A function called for each record. 1487 * @returns {Promise} Resolves once the registrations have been dropped. 1488 */ 1489 _dropRegistrationsIf(predicate) { 1490 return this._db.clearIf(record => { 1491 if (!predicate(record)) { 1492 return false; 1493 } 1494 if (record.hasPermission()) { 1495 // "Clear Recent History" and the Forget button remove permissions 1496 // before clearing registrations, but it's possible for the worker to 1497 // resubscribe if the "dom.push.testing.ignorePermission" pref is set. 1498 this._notifySubscriptionChangeObservers(record); 1499 } 1500 if (!record.isExpired()) { 1501 if (!record.systemRecord) { 1502 Glean.webPush.unsubscribedByClearingData.add(); 1503 } 1504 // Only unregister active registrations, since we already told the 1505 // server about expired ones. 1506 this._backgroundUnregister( 1507 record, 1508 Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL 1509 ); 1510 } 1511 return true; 1512 }); 1513 }, 1514 };