michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "ClientEngine", michael@0: "ClientsRec" michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: Cu.import("resource://services-common/stringbundle.js"); michael@0: Cu.import("resource://services-sync/constants.js"); michael@0: Cu.import("resource://services-sync/engines.js"); michael@0: Cu.import("resource://services-sync/record.js"); michael@0: Cu.import("resource://services-sync/util.js"); michael@0: michael@0: const CLIENTS_TTL = 1814400; // 21 days michael@0: const CLIENTS_TTL_REFRESH = 604800; // 7 days michael@0: michael@0: const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"]; michael@0: michael@0: this.ClientsRec = function ClientsRec(collection, id) { michael@0: CryptoWrapper.call(this, collection, id); michael@0: } michael@0: ClientsRec.prototype = { michael@0: __proto__: CryptoWrapper.prototype, michael@0: _logName: "Sync.Record.Clients", michael@0: ttl: CLIENTS_TTL michael@0: }; michael@0: michael@0: Utils.deferGetSet(ClientsRec, "cleartext", ["name", "type", "commands", "version", "protocols"]); michael@0: michael@0: michael@0: this.ClientEngine = function ClientEngine(service) { michael@0: SyncEngine.call(this, "Clients", service); michael@0: michael@0: // Reset the client on every startup so that we fetch recent clients michael@0: this._resetClient(); michael@0: } michael@0: ClientEngine.prototype = { michael@0: __proto__: SyncEngine.prototype, michael@0: _storeObj: ClientStore, michael@0: _recordObj: ClientsRec, michael@0: _trackerObj: ClientsTracker, michael@0: michael@0: // Always sync client data as it controls other sync behavior michael@0: get enabled() true, michael@0: michael@0: get lastRecordUpload() { michael@0: return Svc.Prefs.get(this.name + ".lastRecordUpload", 0); michael@0: }, michael@0: set lastRecordUpload(value) { michael@0: Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value)); michael@0: }, michael@0: michael@0: // Aggregate some stats on the composition of clients on this account michael@0: get stats() { michael@0: let stats = { michael@0: hasMobile: this.localType == "mobile", michael@0: names: [this.localName], michael@0: numClients: 1, michael@0: }; michael@0: michael@0: for each (let {name, type} in this._store._remoteClients) { michael@0: stats.hasMobile = stats.hasMobile || type == "mobile"; michael@0: stats.names.push(name); michael@0: stats.numClients++; michael@0: } michael@0: michael@0: return stats; michael@0: }, michael@0: michael@0: /** michael@0: * Obtain information about device types. michael@0: * michael@0: * Returns a Map of device types to integer counts. michael@0: */ michael@0: get deviceTypes() { michael@0: let counts = new Map(); michael@0: michael@0: counts.set(this.localType, 1); michael@0: michael@0: for each (let record in this._store._remoteClients) { michael@0: let type = record.type; michael@0: if (!counts.has(type)) { michael@0: counts.set(type, 0); michael@0: } michael@0: michael@0: counts.set(type, counts.get(type) + 1); michael@0: } michael@0: michael@0: return counts; michael@0: }, michael@0: michael@0: get localID() { michael@0: // Generate a random GUID id we don't have one michael@0: let localID = Svc.Prefs.get("client.GUID", ""); michael@0: return localID == "" ? this.localID = Utils.makeGUID() : localID; michael@0: }, michael@0: set localID(value) Svc.Prefs.set("client.GUID", value), michael@0: michael@0: get localName() { michael@0: let localName = Svc.Prefs.get("client.name", ""); michael@0: if (localName != "") michael@0: return localName; michael@0: michael@0: // Generate a client name if we don't have a useful one yet michael@0: let env = Cc["@mozilla.org/process/environment;1"] michael@0: .getService(Ci.nsIEnvironment); michael@0: let user = env.get("USER") || env.get("USERNAME") || michael@0: Svc.Prefs.get("account") || Svc.Prefs.get("username"); michael@0: michael@0: let appName; michael@0: let brand = new StringBundle("chrome://branding/locale/brand.properties"); michael@0: let brandName = brand.get("brandShortName"); michael@0: try { michael@0: let syncStrings = new StringBundle("chrome://browser/locale/sync.properties"); michael@0: appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]); michael@0: } catch (ex) {} michael@0: appName = appName || brandName; michael@0: michael@0: let system = michael@0: // 'device' is defined on unix systems michael@0: Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") || michael@0: // hostname of the system, usually assigned by the user or admin michael@0: Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") || michael@0: // fall back on ua info string michael@0: Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu; michael@0: michael@0: return this.localName = Str.sync.get("client.name2", [user, appName, system]); michael@0: }, michael@0: set localName(value) Svc.Prefs.set("client.name", value), michael@0: michael@0: get localType() Svc.Prefs.get("client.type", "desktop"), michael@0: set localType(value) Svc.Prefs.set("client.type", value), michael@0: michael@0: isMobile: function isMobile(id) { michael@0: if (this._store._remoteClients[id]) michael@0: return this._store._remoteClients[id].type == "mobile"; michael@0: return false; michael@0: }, michael@0: michael@0: _syncStartup: function _syncStartup() { michael@0: // Reupload new client record periodically. michael@0: if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) { michael@0: this._tracker.addChangedID(this.localID); michael@0: this.lastRecordUpload = Date.now() / 1000; michael@0: } michael@0: SyncEngine.prototype._syncStartup.call(this); michael@0: }, michael@0: michael@0: // Always process incoming items because they might have commands michael@0: _reconcile: function _reconcile() { michael@0: return true; michael@0: }, michael@0: michael@0: // Treat reset the same as wiping for locally cached clients michael@0: _resetClient: function _resetClient() this._wipeClient(), michael@0: michael@0: _wipeClient: function _wipeClient() { michael@0: SyncEngine.prototype._resetClient.call(this); michael@0: this._store.wipe(); michael@0: }, michael@0: michael@0: removeClientData: function removeClientData() { michael@0: let res = this.service.resource(this.engineURL + "/" + this.localID); michael@0: res.delete(); michael@0: }, michael@0: michael@0: // Override the default behavior to delete bad records from the server. michael@0: handleHMACMismatch: function handleHMACMismatch(item, mayRetry) { michael@0: this._log.debug("Handling HMAC mismatch for " + item.id); michael@0: michael@0: let base = SyncEngine.prototype.handleHMACMismatch.call(this, item, mayRetry); michael@0: if (base != SyncEngine.kRecoveryStrategy.error) michael@0: return base; michael@0: michael@0: // It's a bad client record. Save it to be deleted at the end of the sync. michael@0: this._log.debug("Bad client record detected. Scheduling for deletion."); michael@0: this._deleteId(item.id); michael@0: michael@0: // Neither try again nor error; we're going to delete it. michael@0: return SyncEngine.kRecoveryStrategy.ignore; michael@0: }, michael@0: michael@0: /** michael@0: * A hash of valid commands that the client knows about. The key is a command michael@0: * and the value is a hash containing information about the command such as michael@0: * number of arguments and description. michael@0: */ michael@0: _commands: { michael@0: resetAll: { args: 0, desc: "Clear temporary local data for all engines" }, michael@0: resetEngine: { args: 1, desc: "Clear temporary local data for engine" }, michael@0: wipeAll: { args: 0, desc: "Delete all client data for all engines" }, michael@0: wipeEngine: { args: 1, desc: "Delete all client data for engine" }, michael@0: logout: { args: 0, desc: "Log out client" }, michael@0: displayURI: { args: 3, desc: "Instruct a client to display a URI" }, michael@0: }, michael@0: michael@0: /** michael@0: * Remove any commands for the local client and mark it for upload. michael@0: */ michael@0: clearCommands: function clearCommands() { michael@0: delete this.localCommands; michael@0: this._tracker.addChangedID(this.localID); michael@0: }, michael@0: michael@0: /** michael@0: * Sends a command+args pair to a specific client. michael@0: * michael@0: * @param command Command string michael@0: * @param args Array of arguments/data for command michael@0: * @param clientId Client to send command to michael@0: */ michael@0: _sendCommandToClient: function sendCommandToClient(command, args, clientId) { michael@0: this._log.trace("Sending " + command + " to " + clientId); michael@0: michael@0: let client = this._store._remoteClients[clientId]; michael@0: if (!client) { michael@0: throw new Error("Unknown remote client ID: '" + clientId + "'."); michael@0: } michael@0: michael@0: // notDupe compares two commands and returns if they are not equal. michael@0: let notDupe = function(other) { michael@0: return other.command != command || !Utils.deepEquals(other.args, args); michael@0: }; michael@0: michael@0: let action = { michael@0: command: command, michael@0: args: args, michael@0: }; michael@0: michael@0: if (!client.commands) { michael@0: client.commands = [action]; michael@0: } michael@0: // Add the new action if there are no duplicates. michael@0: else if (client.commands.every(notDupe)) { michael@0: client.commands.push(action); michael@0: } michael@0: // It must be a dupe. Skip. michael@0: else { michael@0: return; michael@0: } michael@0: michael@0: this._log.trace("Client " + clientId + " got a new action: " + [command, args]); michael@0: this._tracker.addChangedID(clientId); michael@0: }, michael@0: michael@0: /** michael@0: * Check if the local client has any remote commands and perform them. michael@0: * michael@0: * @return false to abort sync michael@0: */ michael@0: processIncomingCommands: function processIncomingCommands() { michael@0: return this._notify("clients:process-commands", "", function() { michael@0: let commands = this.localCommands; michael@0: michael@0: // Immediately clear out the commands as we've got them locally. michael@0: this.clearCommands(); michael@0: michael@0: // Process each command in order. michael@0: for each ({command: command, args: args} in commands) { michael@0: this._log.debug("Processing command: " + command + "(" + args + ")"); michael@0: michael@0: let engines = [args[0]]; michael@0: switch (command) { michael@0: case "resetAll": michael@0: engines = null; michael@0: // Fallthrough michael@0: case "resetEngine": michael@0: this.service.resetClient(engines); michael@0: break; michael@0: case "wipeAll": michael@0: engines = null; michael@0: // Fallthrough michael@0: case "wipeEngine": michael@0: this.service.wipeClient(engines); michael@0: break; michael@0: case "logout": michael@0: this.service.logout(); michael@0: return false; michael@0: case "displayURI": michael@0: this._handleDisplayURI.apply(this, args); michael@0: break; michael@0: default: michael@0: this._log.debug("Received an unknown command: " + command); michael@0: break; michael@0: } michael@0: } michael@0: michael@0: return true; michael@0: })(); michael@0: }, michael@0: michael@0: /** michael@0: * Validates and sends a command to a client or all clients. michael@0: * michael@0: * Calling this does not actually sync the command data to the server. If the michael@0: * client already has the command/args pair, it won't receive a duplicate michael@0: * command. michael@0: * michael@0: * @param command michael@0: * Command to invoke on remote clients michael@0: * @param args michael@0: * Array of arguments to give to the command michael@0: * @param clientId michael@0: * Client ID to send command to. If undefined, send to all remote michael@0: * clients. michael@0: */ michael@0: sendCommand: function sendCommand(command, args, clientId) { michael@0: let commandData = this._commands[command]; michael@0: // Don't send commands that we don't know about. michael@0: if (!commandData) { michael@0: this._log.error("Unknown command to send: " + command); michael@0: return; michael@0: } michael@0: // Don't send a command with the wrong number of arguments. michael@0: else if (!args || args.length != commandData.args) { michael@0: this._log.error("Expected " + commandData.args + " args for '" + michael@0: command + "', but got " + args); michael@0: return; michael@0: } michael@0: michael@0: if (clientId) { michael@0: this._sendCommandToClient(command, args, clientId); michael@0: } else { michael@0: for (let id in this._store._remoteClients) { michael@0: this._sendCommandToClient(command, args, id); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Send a URI to another client for display. michael@0: * michael@0: * A side effect is the score is increased dramatically to incur an michael@0: * immediate sync. michael@0: * michael@0: * If an unknown client ID is specified, sendCommand() will throw an michael@0: * Error object. michael@0: * michael@0: * @param uri michael@0: * URI (as a string) to send and display on the remote client michael@0: * @param clientId michael@0: * ID of client to send the command to. If not defined, will be sent michael@0: * to all remote clients. michael@0: * @param title michael@0: * Title of the page being sent. michael@0: */ michael@0: sendURIToClientForDisplay: function sendURIToClientForDisplay(uri, clientId, title) { michael@0: this._log.info("Sending URI to client: " + uri + " -> " + michael@0: clientId + " (" + title + ")"); michael@0: this.sendCommand("displayURI", [uri, this.localID, title], clientId); michael@0: michael@0: this._tracker.score += SCORE_INCREMENT_XLARGE; michael@0: }, michael@0: michael@0: /** michael@0: * Handle a single received 'displayURI' command. michael@0: * michael@0: * Interested parties should observe the "weave:engine:clients:display-uri" michael@0: * topic. The callback will receive an object as the subject parameter with michael@0: * the following keys: michael@0: * michael@0: * uri URI (string) that is requested for display. michael@0: * clientId ID of client that sent the command. michael@0: * title Title of page that loaded URI (likely) corresponds to. michael@0: * michael@0: * The 'data' parameter to the callback will not be defined. michael@0: * michael@0: * @param uri michael@0: * String URI that was received michael@0: * @param clientId michael@0: * ID of client that sent URI michael@0: * @param title michael@0: * String title of page that URI corresponds to. Older clients may not michael@0: * send this. michael@0: */ michael@0: _handleDisplayURI: function _handleDisplayURI(uri, clientId, title) { michael@0: this._log.info("Received a URI for display: " + uri + " (" + title + michael@0: ") from " + clientId); michael@0: michael@0: let subject = {uri: uri, client: clientId, title: title}; michael@0: Svc.Obs.notify("weave:engine:clients:display-uri", subject); michael@0: } michael@0: }; michael@0: michael@0: function ClientStore(name, engine) { michael@0: Store.call(this, name, engine); michael@0: } michael@0: ClientStore.prototype = { michael@0: __proto__: Store.prototype, michael@0: michael@0: create: function create(record) this.update(record), michael@0: michael@0: update: function update(record) { michael@0: // Only grab commands from the server; local name/type always wins michael@0: if (record.id == this.engine.localID) michael@0: this.engine.localCommands = record.commands; michael@0: else michael@0: this._remoteClients[record.id] = record.cleartext; michael@0: }, michael@0: michael@0: createRecord: function createRecord(id, collection) { michael@0: let record = new ClientsRec(collection, id); michael@0: michael@0: // Package the individual components into a record for the local client michael@0: if (id == this.engine.localID) { michael@0: record.name = this.engine.localName; michael@0: record.type = this.engine.localType; michael@0: record.commands = this.engine.localCommands; michael@0: record.version = Services.appinfo.version; michael@0: record.protocols = SUPPORTED_PROTOCOL_VERSIONS; michael@0: } michael@0: else michael@0: record.cleartext = this._remoteClients[id]; michael@0: michael@0: return record; michael@0: }, michael@0: michael@0: itemExists: function itemExists(id) id in this.getAllIDs(), michael@0: michael@0: getAllIDs: function getAllIDs() { michael@0: let ids = {}; michael@0: ids[this.engine.localID] = true; michael@0: for (let id in this._remoteClients) michael@0: ids[id] = true; michael@0: return ids; michael@0: }, michael@0: michael@0: wipe: function wipe() { michael@0: this._remoteClients = {}; michael@0: }, michael@0: }; michael@0: michael@0: function ClientsTracker(name, engine) { michael@0: Tracker.call(this, name, engine); michael@0: Svc.Obs.add("weave:engine:start-tracking", this); michael@0: Svc.Obs.add("weave:engine:stop-tracking", this); michael@0: } michael@0: ClientsTracker.prototype = { michael@0: __proto__: Tracker.prototype, michael@0: michael@0: _enabled: false, michael@0: michael@0: observe: function observe(subject, topic, data) { michael@0: switch (topic) { michael@0: case "weave:engine:start-tracking": michael@0: if (!this._enabled) { michael@0: Svc.Prefs.observe("client.name", this); michael@0: this._enabled = true; michael@0: } michael@0: break; michael@0: case "weave:engine:stop-tracking": michael@0: if (this._enabled) { michael@0: Svc.Prefs.ignore("clients.name", this); michael@0: this._enabled = false; michael@0: } michael@0: break; michael@0: case "nsPref:changed": michael@0: this._log.debug("client.name preference changed"); michael@0: this.addChangedID(Svc.Prefs.get("client.GUID")); michael@0: this.score += SCORE_INCREMENT_XLARGE; michael@0: break; michael@0: } michael@0: } michael@0: };