gecko/browser/components/loop/MozLoopService.jsm

632 lines
21 KiB
JavaScript

/* 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;
// Invalid auth token as per
// https://github.com/mozilla-services/loop-server/blob/45787d34108e2f0d87d74d4ddf4ff0dbab23501c/loop/errno.json#L6
const INVALID_AUTH_TOKEN = 110;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/osfile.jsm", this);
this.EXPORTED_SYMBOLS = ["MozLoopService"];
XPCOMUtils.defineLazyModuleGetter(this, "console",
"resource://gre/modules/devtools/Console.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI",
"resource:///modules/loop/MozLoopAPI.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "convertToRTCStatsReport",
"resource://gre/modules/media/RTCStatsReport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Chat", "resource:///modules/Chat.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
"resource://services-common/utils.js");
XPCOMUtils.defineLazyModuleGetter(this, "CryptoUtils",
"resource://services-crypto/utils.js");
XPCOMUtils.defineLazyModuleGetter(this, "HawkClient",
"resource://services-common/hawkclient.js");
XPCOMUtils.defineLazyModuleGetter(this, "deriveHawkCredentials",
"resource://services-common/hawkrequest.js");
XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
"resource:///modules/loop/MozLoopPushHandler.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
// The current deferred for the registration process. This is set if in progress
// or the registration was successful. This is null if a registration attempt was
// unsuccessful.
let gRegisteredDeferred = null;
let gPushHandler = null;
let gHawkClient = null;
let gRegisteredLoopServer = false;
let gLocalizedStrings = null;
let gInitializeTimer = null;
/**
* Internal helper methods and state
*
* The registration is a two-part process. First we need to connect to
* and register with the push server. Then we need to take the result of that
* and register with the Loop server.
*/
let MozLoopServiceInternal = {
// The uri of the Loop server.
get loopServerUri() Services.prefs.getCharPref("loop.server"),
/**
* The initial delay for push registration. This ensures we don't start
* kicking off straight after browser startup, just a few seconds later.
*/
get initialRegistrationDelayMilliseconds() {
try {
// Let a pref override this for developer & testing use.
return Services.prefs.getIntPref("loop.initialDelay");
} catch (x) {
// Default to 5 seconds
return 5000;
}
return initialDelay;
},
/**
* Gets the current latest expiry time for urls.
*
* In seconds since epoch.
*/
get expiryTimeSeconds() {
try {
return Services.prefs.getIntPref("loop.urlsExpiryTimeSeconds");
} catch (x) {
// It is ok for the pref not to exist.
return 0;
}
},
/**
* Sets the expiry time to either the specified time, or keeps it the same
* depending on which is latest.
*/
set expiryTimeSeconds(time) {
if (time > this.expiryTimeSeconds) {
Services.prefs.setIntPref("loop.urlsExpiryTimeSeconds", time);
}
},
/**
* Returns true if the expiry time is in the future.
*/
urlExpiryTimeIsInFuture: function() {
return this.expiryTimeSeconds * 1000 > Date.now();
},
/**
* Retrieves MozLoopService "do not disturb" pref value.
*
* @return {Boolean} aFlag
*/
get doNotDisturb() {
return Services.prefs.getBoolPref("loop.do_not_disturb");
},
/**
* Sets MozLoopService "do not disturb" pref value.
*
* @param {Boolean} aFlag
*/
set doNotDisturb(aFlag) {
Services.prefs.setBoolPref("loop.do_not_disturb", Boolean(aFlag));
},
/**
* Starts registration of Loop with the push server, and then will register
* with the Loop server. It will return early if already registered.
*
* @param {Object} mockPushHandler Optional, test-only mock push handler. Used
* to allow mocking of the MozLoopPushHandler.
* @returns {Promise} a promise that is resolved with no params on completion, or
* rejected with an error code or string.
*/
promiseRegisteredWithServers: function(mockPushHandler) {
if (gRegisteredDeferred) {
return gRegisteredDeferred.promise;
}
gRegisteredDeferred = Promise.defer();
// We grab the promise early in case .initialize or its results sets
// it back to null on error.
let result = gRegisteredDeferred.promise;
gPushHandler = mockPushHandler || MozLoopPushHandler;
gPushHandler.initialize(this.onPushRegistered.bind(this),
this.onHandleNotification.bind(this));
return result;
},
/**
* Performs a hawk based request to the loop server.
*
* @param {String} path The path to make the request to.
* @param {String} method The request method, e.g. 'POST', 'GET'.
* @param {Object} payloadObj An object which is converted to JSON and
* transmitted with the request.
* @returns {Promise}
* Returns a promise that resolves to the response of the API call,
* or is rejected with an error. If the server response can be parsed
* as JSON and contains an 'error' property, the promise will be
* rejected with this JSON-parsed response.
*/
hawkRequest: function(path, method, payloadObj) {
if (!gHawkClient) {
gHawkClient = new HawkClient(this.loopServerUri);
}
let sessionToken;
try {
sessionToken = Services.prefs.getCharPref("loop.hawk-session-token");
} catch (x) {
// It is ok for this not to exist, we'll default to sending no-creds
}
let credentials;
if (sessionToken) {
// true = use a hex key, as required by the server (see bug 1032738).
credentials = deriveHawkCredentials(sessionToken, "sessionToken",
2 * 32, true);
}
return gHawkClient.request(path, method, credentials, payloadObj);
},
/**
* Used to store a session token from a request if it exists in the headers.
*
* @param {Object} headers The request headers, which may include a
* "hawk-session-token" to be saved.
* @return true on success or no token, false on failure.
*/
storeSessionToken: function(headers) {
let sessionToken = headers["hawk-session-token"];
if (sessionToken) {
// XXX should do more validation here
if (sessionToken.length === 64) {
Services.prefs.setCharPref("loop.hawk-session-token", sessionToken);
} else {
// XXX Bubble the precise details up to the UI somehow (bug 1013248).
console.warn("Loop server sent an invalid session token");
gRegisteredDeferred.reject("session-token-wrong-size");
gRegisteredDeferred = null;
return false;
}
}
return true;
},
/**
* Callback from MozLoopPushHandler - The push server has been registered
* and has given us a push url.
*
* @param {String} pushUrl The push url given by the push server.
*/
onPushRegistered: function(err, pushUrl) {
if (err) {
gRegisteredDeferred.reject(err);
gRegisteredDeferred = null;
return;
}
this.registerWithLoopServer(pushUrl);
},
/**
* Registers with the Loop server.
*
* @param {String} pushUrl The push url given by the push server.
* @param {Boolean} noRetry Optional, don't retry if authentication fails.
*/
registerWithLoopServer: function(pushUrl, noRetry) {
this.hawkRequest("/registration", "POST", { simplePushURL: pushUrl})
.then((response) => {
// If this failed we got an invalid token. storeSessionToken rejects
// the gRegisteredDeferred promise for us, so here we just need to
// early return.
if (!this.storeSessionToken(response.headers))
return;
gRegisteredDeferred.resolve();
// No need to clear the promise here, everything was good, so we don't need
// to re-register.
}, (error) => {
// There's other errors than invalid auth token, but we should only do the reset
// as a last resort.
if (error.code === 401 && error.errno === INVALID_AUTH_TOKEN) {
if (this.urlExpiryTimeIsInFuture()) {
// XXX Should this be reported to the user is a visible manner?
Cu.reportError("Loop session token is invalid, all previously "
+ "generated urls will no longer work.");
}
// Authorization failed, invalid token, we need to try again with a new token.
Services.prefs.clearUserPref("loop.hawk-session-token");
this.registerWithLoopServer(pushUrl, true);
return;
}
// XXX Bubble the precise details up to the UI somehow (bug 1013248).
Cu.reportError("Failed to register with the loop server. error: " + error);
gRegisteredDeferred.reject(error.errno);
gRegisteredDeferred = null;
}
);
},
/**
* Callback from MozLoopPushHandler - A push notification has been received from
* the server.
*
* @param {String} version The version information from the server.
*/
onHandleNotification: function(version) {
if (this.doNotDisturb) {
return;
}
this.openChatWindow(null, "LooP", "about:loopconversation#incoming/" + version);
},
/**
* A getter to obtain and store the strings for loop. This is structured
* for use by l10n.js.
*
* @returns {Object} a map of element ids with attributes to set.
*/
get localizedStrings() {
if (gLocalizedStrings)
return gLocalizedStrings;
var stringBundle =
Services.strings.createBundle('chrome://browser/locale/loop/loop.properties');
var map = {};
var enumerator = stringBundle.getSimpleEnumeration();
while (enumerator.hasMoreElements()) {
var string = enumerator.getNext().QueryInterface(Ci.nsIPropertyElement);
// 'textContent' is the default attribute to set if none are specified.
var key = string.key, property = 'textContent';
var i = key.lastIndexOf('.');
if (i >= 0) {
property = key.substring(i + 1);
key = key.substring(0, i);
}
if (!(key in map))
map[key] = {};
map[key][property] = string.value;
}
return gLocalizedStrings = map;
},
/**
* Saves loop logs to the saved-telemetry-pings folder.
*
* @param {Object} pc The peerConnection in question.
*/
stageForTelemetryUpload: function(window, pc) {
window.WebrtcGlobalInformation.getAllStats(allStats => {
let internalFormat = allStats.reports[0]; // filtered on pc.id
window.WebrtcGlobalInformation.getLogging('', logs => {
let report = convertToRTCStatsReport(internalFormat);
let logStr = "";
logs.forEach(s => { logStr += s + "\n"; });
// We have stats and logs.
// Create worker job. ping = saved telemetry ping file header + payload
//
// Prepare payload according to https://wiki.mozilla.org/Loop/Telemetry
let ai = Services.appinfo;
let uuid = uuidgen.generateUUID().toString();
uuid = uuid.substr(1,uuid.length-2); // remove uuid curly braces
let directory = OS.Path.join(OS.Constants.Path.profileDir,
"saved-telemetry-pings");
let job = {
directory: directory,
filename: uuid + ".json",
ping: {
reason: "loop",
slug: uuid,
payload: {
ver: 1,
info: {
appUpdateChannel: ai.defaultUpdateChannel,
appBuildID: ai.appBuildID,
appName: ai.name,
appVersion: ai.version,
reason: "loop",
OS: ai.OS,
version: Services.sysinfo.getProperty("version")
},
report: "ice failure",
connectionstate: pc.iceConnectionState,
stats: report,
localSdp: internalFormat.localSdp,
remoteSdp: internalFormat.remoteSdp,
log: logStr
}
}
};
// Send job to worker to do log sanitation, transcoding and saving to
// disk for pickup by telemetry on next startup, which then uploads it.
let worker = new ChromeWorker("MozLoopWorker.js");
worker.onmessage = function(e) {
console.log(e.data.ok ?
"Successfully staged loop report for telemetry upload." :
("Failed to stage loop report. Error: " + e.data.fail));
}
worker.postMessage(job);
});
}, pc.id);
},
/**
* Opens the chat window
*
* @param {Object} contentWindow The window to open the chat window in, may
* be null.
* @param {String} title The title of the chat window.
* @param {String} url The page to load in the chat window.
*/
openChatWindow: function(contentWindow, title, url) {
// So I guess the origin is the loop server!?
let origin = this.loopServerUri;
url = url.spec || url;
let callback = chatbox => {
// We need to use DOMContentLoaded as otherwise the injection will happen
// in about:blank and then get lost.
// Sadly we can't use chatbox.promiseChatLoaded() as promise chaining
// involves event loop spins, which means it might be too late.
// Have we already done it?
if (chatbox.contentWindow.navigator.mozLoop) {
return;
}
chatbox.addEventListener("DOMContentLoaded", function loaded(event) {
if (event.target != chatbox.contentDocument) {
return;
}
chatbox.removeEventListener("DOMContentLoaded", loaded, true);
let window = chatbox.contentWindow;
injectLoopAPI(window);
let ourID = window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;
let onPCLifecycleChange = (pc, winID, type) => {
if (winID != ourID) {
return;
}
if (type == "iceconnectionstatechange") {
switch(pc.iceConnectionState) {
case "failed":
case "disconnected":
if (Services.telemetry.canSend ||
Services.prefs.getBoolPref("toolkit.telemetry.test")) {
this.stageForTelemetryUpload(window, pc);
}
break;
}
}
};
let pc_static = new window.mozRTCPeerConnectionStatic();
pc_static.registerPeerConnectionLifecycleCallback(onPCLifecycleChange);
}.bind(this), true);
};
Chat.open(contentWindow, origin, title, url, undefined, undefined, callback);
}
};
Object.freeze(MozLoopServiceInternal);
let gInitializeTimerFunc = () => {
// Kick off the push notification service into registering after a timeout
// this ensures we're not doing too much straight after the browser's finished
// starting up.
gInitializeTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
gInitializeTimer.initWithCallback(() => {
MozLoopService.register();
gInitializeTimer = null;
},
MozLoopServiceInternal.initialRegistrationDelayMilliseconds, Ci.nsITimer.TYPE_ONE_SHOT);
};
/**
* Public API
*/
this.MozLoopService = {
set initializeTimerFunc(value) {
gInitializeTimerFunc = value;
},
/**
* Initialized the loop service, and starts registration with the
* push and loop servers.
*/
initialize: function() {
// Don't do anything if loop is not enabled.
if (!Services.prefs.getBoolPref("loop.enabled")) {
return;
}
// If expiresTime is in the future then kick-off registration.
if (MozLoopServiceInternal.urlExpiryTimeIsInFuture()) {
gInitializeTimerFunc();
}
},
/**
* Starts registration of Loop with the push server, and then will register
* with the Loop server. It will return early if already registered.
*
* @param {Object} mockPushHandler Optional, test-only mock push handler. Used
* to allow mocking of the MozLoopPushHandler.
* @returns {Promise} a promise that is resolved with no params on completion, or
* rejected with an error code or string.
*/
register: function(mockPushHandler) {
// Don't do anything if loop is not enabled.
if (!Services.prefs.getBoolPref("loop.enabled")) {
throw new Error("Loop is not enabled");
}
return MozLoopServiceInternal.promiseRegisteredWithServers(mockPushHandler);
},
/**
* Used to note a call url expiry time. If the time is later than the current
* latest expiry time, then the stored expiry time is increased. For times
* sooner, this function is a no-op; this ensures we always have the latest
* expiry time for a url.
*
* This is used to deterimine whether or not we should be registering with the
* push server on start.
*
* @param {Integer} expiryTimeSeconds The seconds since epoch of the expiry time
* of the url.
*/
noteCallUrlExpiry: function(expiryTimeSeconds) {
MozLoopServiceInternal.expiryTimeSeconds = expiryTimeSeconds;
},
/**
* Returns the strings for the specified element. Designed for use
* with l10n.js.
*
* @param {key} The element id to get strings for.
* @return {String} A JSON string containing the localized
* attribute/value pairs for the element.
*/
getStrings: function(key) {
var stringData = MozLoopServiceInternal.localizedStrings;
if (!(key in stringData)) {
Cu.reportError('No string for key: ' + key + 'found');
return "";
}
return JSON.stringify(stringData[key]);
},
/**
* Retrieves MozLoopService "do not disturb" value.
*
* @return {Boolean}
*/
get doNotDisturb() {
return MozLoopServiceInternal.doNotDisturb;
},
/**
* Sets MozLoopService "do not disturb" value.
*
* @param {Boolean} aFlag
*/
set doNotDisturb(aFlag) {
MozLoopServiceInternal.doNotDisturb = aFlag;
},
/**
* Returns the current locale
*
* @return {String} The code of the current locale.
*/
get locale() {
try {
return Services.prefs.getComplexValue("general.useragent.locale",
Ci.nsISupportsString).data;
} catch (ex) {
return "en-US";
}
},
/**
* Set any character preference under "loop.".
*
* @param {String} prefName The name of the pref without the preceding "loop."
* @param {String} value The value to set.
*
* Any errors thrown by the Mozilla pref API are logged to the console.
*/
setLoopCharPref: function(prefName, value) {
try {
Services.prefs.setCharPref("loop." + prefName, value);
} catch (ex) {
console.log("setLoopCharPref had trouble setting " + prefName +
"; exception: " + ex);
}
},
/**
* Return any preference under "loop." that's coercible to a character
* preference.
*
* @param {String} prefName The name of the pref without the preceding
* "loop."
*
* Any errors thrown by the Mozilla pref API are logged to the console
* and cause null to be returned. This includes the case of the preference
* not being found.
*
* @return {String} on success, null on error
*/
getLoopCharPref: function(prefName) {
try {
return Services.prefs.getCharPref("loop." + prefName);
} catch (ex) {
console.log("getLoopCharPref had trouble getting " + prefName +
"; exception: " + ex);
return null;
}
},
/**
* Performs a hawk based request to the loop server.
*
* @param {String} path The path to make the request to.
* @param {String} method The request method, e.g. 'POST', 'GET'.
* @param {Object} payloadObj An object which is converted to JSON and
* transmitted with the request.
* @returns {Promise}
* Returns a promise that resolves to the response of the API call,
* or is rejected with an error. If the server response can be parsed
* as JSON and contains an 'error' property, the promise will be
* rejected with this JSON-parsed response.
*/
hawkRequest: function(path, method, payloadObj) {
return MozLoopServiceInternal.hawkRequest(path, method, payloadObj);
},
};
Object.freeze(this.MozLoopService);