/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const { classes: Cc, interfaces: Ci, utils: Cu } = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); this.EXPORTED_SYMBOLS = ["MozLoopPushHandler"]; XPCOMUtils.defineLazyModuleGetter(this, "console", "resource://gre/modules/devtools/Console.jsm"); /** * We don't have push notifications on desktop currently, so this is a * workaround to get them going for us. */ let MozLoopPushHandler = { // This is the uri of the push server. pushServerUri: undefined, // This is the channel id we're using for notifications channelID: "8b1081ce-9b35-42b5-b8f5-3ff8cb813a50", // This is the UserAgent UUID assigned by the PushServer uaID: undefined, // Stores the push url if we're registered and we have one. pushUrl: undefined, // Set to true once the channelID has been registered with the PushServer. registered: false, _minRetryDelay_ms: (() => { try { return Services.prefs.getIntPref("loop.retry_delay.start") } catch (e) { return 60000 // 1 minute } })(), _maxRetryDelay_ms: (() => { try { return Services.prefs.getIntPref("loop.retry_delay.limit") } catch (e) { return 300000 // 5 minutes } })(), /** * Starts a connection to the push socket server. On * connection, it will automatically say hello and register the channel * id with the server. * * Register callback parameters: * - {String|null} err: Encountered error, if any * - {String} url: The push url obtained from the server * * Callback parameters: * - {String} version The version string received from the push server for * the notification. * * @param {Function} registerCallback Callback to be called once we are * registered. * @param {Function} notificationCallback Callback to be called when a * push notification is received (may be called multiple * times). * @param {Object} mockPushHandler Optional, test-only object, to allow * the websocket to be mocked for tests. */ initialize: function(registerCallback, notificationCallback, mockPushHandler) { if (mockPushHandler) { this._mockPushHandler = mockPushHandler; } this._registerCallback = registerCallback; this._notificationCallback = notificationCallback; this._openSocket(); }, /** * Listener method, handles the start of the websocket stream. * Sends a hello message to the server. * * @param {nsISupports} aContext Not used */ onStart: function() { this._retryEnd(); // If a uaID has already been assigned, assume this is a re-connect // and send the uaID in order to re-synch with the // PushServer. If a registration has been completed, send the channelID. let helloMsg = { messageType: "hello", uaid: this.uaID, channelIDs: this.registered ? [this.channelID] :[] }; this._retryOperation(() => this.onStart(), this._maxRetryDelay_ms); try { // in case websocket has closed before this handler is run this._websocket.sendMsg(JSON.stringify(helloMsg)); } catch (e) {console.warn("MozLoopPushHandler::onStart websocket.sendMsg() failure");} }, /** * Listener method, called when the websocket is closed. * * @param {nsISupports} aContext Not used * @param {nsresult} aStatusCode Reason for stopping (NS_OK = successful) */ onStop: function(aContext, aStatusCode) { Cu.reportError("Loop Push server web socket closed! Code: " + aStatusCode); this._retryOperation(() => this._openSocket()); }, /** * Listener method, called when the websocket is closed by the server. * If there are errors, onStop may be called without ever calling this * method. * * @param {nsISupports} aContext Not used * @param {integer} aCode the websocket closing handshake close code * @param {String} aReason the websocket closing handshake close reason */ onServerClose: function(aContext, aCode) { Cu.reportError("Loop Push server web socket closed (server)! Code: " + aCode); this._retryOperation(() => this._openSocket()); }, /** * Listener method, called when the websocket receives a message. * * @param {nsISupports} aContext Not used * @param {String} aMsg The message data */ onMessageAvailable: function(aContext, aMsg) { let msg = JSON.parse(aMsg); switch(msg.messageType) { case "hello": this._retryEnd(); if (this.uaID !== msg.uaid) { this.uaID = msg.uaid; this._registerChannel(); } break; case "register": this._onRegister(msg); break; case "notification": msg.updates.forEach((update) => { if (update.channelID === this.channelID) { this._notificationCallback(update.version); } }); break; } }, /** * Handles the PushServer registration response. * * @param {} msg PushServer to UserAgent registration response (parsed from JSON). */ _onRegister: function(msg) { switch (msg.status) { case 200: this._retryEnd(); // reset retry mechanism this.registered = true; if (this.pushUrl !== msg.pushEndpoint) { this.pushUrl = msg.pushEndpoint; this._registerCallback(null, this.pushUrl); } break; case 500: // retry the registration request after a suitable delay this._retryOperation(() => this._registerChannel()); break; case 409: this._registerCallback("error: PushServer ChannelID already in use"); break; default: this._registerCallback("error: PushServer registration failure, status = " + msg.status); break; } }, /** * Attempts to open a websocket. * * A new websocket interface is used each time. If an onStop callback * was received, calling asyncOpen() on the same interface will * trigger a "alreay open socket" exception even though the channel * is logically closed. */ _openSocket: function() { if (this._mockPushHandler) { // For tests, use the mock instance. this._websocket = this._mockPushHandler; } else if (!Services.io.offline) { this._websocket = Cc["@mozilla.org/network/protocol;1?name=wss"] .createInstance(Ci.nsIWebSocketChannel); } else { this._registerCallback("offline"); console.warn("MozLoopPushHandler - IO offline"); return; } this._websocket.protocol = "push-notification"; let performOpen = () => { let uri = Services.io.newURI(this.pushServerUri, null, null); this._websocket.asyncOpen(uri, this.pushServerUri, this, null); } let pushServerURLFetchError = () => { console.warn("MozLoopPushHandler - Could not retrieve push server URL from Loop server; using default"); this.pushServerUri = Services.prefs.getCharPref("services.push.serverURL"); performOpen(); } if (!this.pushServerUri) { // Get push server to use from the Loop server let pushUrlEndpoint = Services.prefs.getCharPref("loop.server") + "/push-server-config"; let req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. createInstance(Ci.nsIXMLHttpRequest); req.open("GET", pushUrlEndpoint); req.onload = () => { if (req.status >= 200 && req.status < 300) { let pushServerConfig; try { pushServerConfig = JSON.parse(req.responseText); } catch (e) { console.warn("MozLoopPushHandler - Error parsing JSON response for push server URL"); pushServerURLFetchError(); } if (pushServerConfig.pushServerURI) { this.pushServerUri = pushServerConfig.pushServerURI; performOpen(); } else { console.warn("MozLoopPushHandler - push server URL config lacks pushServerURI parameter"); pushServerURLFetchError(); } } else { console.warn("MozLoopPushHandler - push server URL retrieve error: " + req.status); pushServerURLFetchError(); } }; req.onerror = pushServerURLFetchError; req.send(); } else { // this.pushServerUri already set -- just open the channel performOpen(); } }, /** * Handles registering a service */ _registerChannel: function() { this.registered = false; try { // in case websocket has closed this._websocket.sendMsg(JSON.stringify({messageType: "register", channelID: this.channelID})); } catch (e) {console.warn("MozLoopPushHandler::_registerChannel websocket.sendMsg() failure");} }, /** * Method to handle retrying UserAgent to PushServer request following * a retry back-off scheme managed by this function. * * @param {function} delayedOp Function to call after current delay is satisfied * * @param {number} [optional] retryDelay This parameter will be used as the initial delay */ _retryOperation: function(delayedOp, retryDelay) { if (!this._retryCount) { this._retryDelay = retryDelay || this._minRetryDelay_ms; this._retryCount = 1; } else { let nextDelay = this._retryDelay * 2; this._retryDelay = nextDelay > this._maxRetryDelay_ms ? this._maxRetryDelay_ms : nextDelay; this._retryCount += 1; } this._timeoutID = setTimeout(delayedOp, this._retryDelay); }, /** * Method used to reset the retry delay back-off logic. * */ _retryEnd: function() { if (this._retryCount) { clearTimeout(this._timeoutID); this._retryCount = 0; } } };