Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
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/. */
5 this.EXPORTED_SYMBOLS = [
6 "ClientEngine",
7 "ClientsRec"
8 ];
10 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
12 Cu.import("resource://services-common/stringbundle.js");
13 Cu.import("resource://services-sync/constants.js");
14 Cu.import("resource://services-sync/engines.js");
15 Cu.import("resource://services-sync/record.js");
16 Cu.import("resource://services-sync/util.js");
18 const CLIENTS_TTL = 1814400; // 21 days
19 const CLIENTS_TTL_REFRESH = 604800; // 7 days
21 const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"];
23 this.ClientsRec = function ClientsRec(collection, id) {
24 CryptoWrapper.call(this, collection, id);
25 }
26 ClientsRec.prototype = {
27 __proto__: CryptoWrapper.prototype,
28 _logName: "Sync.Record.Clients",
29 ttl: CLIENTS_TTL
30 };
32 Utils.deferGetSet(ClientsRec, "cleartext", ["name", "type", "commands", "version", "protocols"]);
35 this.ClientEngine = function ClientEngine(service) {
36 SyncEngine.call(this, "Clients", service);
38 // Reset the client on every startup so that we fetch recent clients
39 this._resetClient();
40 }
41 ClientEngine.prototype = {
42 __proto__: SyncEngine.prototype,
43 _storeObj: ClientStore,
44 _recordObj: ClientsRec,
45 _trackerObj: ClientsTracker,
47 // Always sync client data as it controls other sync behavior
48 get enabled() true,
50 get lastRecordUpload() {
51 return Svc.Prefs.get(this.name + ".lastRecordUpload", 0);
52 },
53 set lastRecordUpload(value) {
54 Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value));
55 },
57 // Aggregate some stats on the composition of clients on this account
58 get stats() {
59 let stats = {
60 hasMobile: this.localType == "mobile",
61 names: [this.localName],
62 numClients: 1,
63 };
65 for each (let {name, type} in this._store._remoteClients) {
66 stats.hasMobile = stats.hasMobile || type == "mobile";
67 stats.names.push(name);
68 stats.numClients++;
69 }
71 return stats;
72 },
74 /**
75 * Obtain information about device types.
76 *
77 * Returns a Map of device types to integer counts.
78 */
79 get deviceTypes() {
80 let counts = new Map();
82 counts.set(this.localType, 1);
84 for each (let record in this._store._remoteClients) {
85 let type = record.type;
86 if (!counts.has(type)) {
87 counts.set(type, 0);
88 }
90 counts.set(type, counts.get(type) + 1);
91 }
93 return counts;
94 },
96 get localID() {
97 // Generate a random GUID id we don't have one
98 let localID = Svc.Prefs.get("client.GUID", "");
99 return localID == "" ? this.localID = Utils.makeGUID() : localID;
100 },
101 set localID(value) Svc.Prefs.set("client.GUID", value),
103 get localName() {
104 let localName = Svc.Prefs.get("client.name", "");
105 if (localName != "")
106 return localName;
108 // Generate a client name if we don't have a useful one yet
109 let env = Cc["@mozilla.org/process/environment;1"]
110 .getService(Ci.nsIEnvironment);
111 let user = env.get("USER") || env.get("USERNAME") ||
112 Svc.Prefs.get("account") || Svc.Prefs.get("username");
114 let appName;
115 let brand = new StringBundle("chrome://branding/locale/brand.properties");
116 let brandName = brand.get("brandShortName");
117 try {
118 let syncStrings = new StringBundle("chrome://browser/locale/sync.properties");
119 appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]);
120 } catch (ex) {}
121 appName = appName || brandName;
123 let system =
124 // 'device' is defined on unix systems
125 Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
126 // hostname of the system, usually assigned by the user or admin
127 Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
128 // fall back on ua info string
129 Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
131 return this.localName = Str.sync.get("client.name2", [user, appName, system]);
132 },
133 set localName(value) Svc.Prefs.set("client.name", value),
135 get localType() Svc.Prefs.get("client.type", "desktop"),
136 set localType(value) Svc.Prefs.set("client.type", value),
138 isMobile: function isMobile(id) {
139 if (this._store._remoteClients[id])
140 return this._store._remoteClients[id].type == "mobile";
141 return false;
142 },
144 _syncStartup: function _syncStartup() {
145 // Reupload new client record periodically.
146 if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) {
147 this._tracker.addChangedID(this.localID);
148 this.lastRecordUpload = Date.now() / 1000;
149 }
150 SyncEngine.prototype._syncStartup.call(this);
151 },
153 // Always process incoming items because they might have commands
154 _reconcile: function _reconcile() {
155 return true;
156 },
158 // Treat reset the same as wiping for locally cached clients
159 _resetClient: function _resetClient() this._wipeClient(),
161 _wipeClient: function _wipeClient() {
162 SyncEngine.prototype._resetClient.call(this);
163 this._store.wipe();
164 },
166 removeClientData: function removeClientData() {
167 let res = this.service.resource(this.engineURL + "/" + this.localID);
168 res.delete();
169 },
171 // Override the default behavior to delete bad records from the server.
172 handleHMACMismatch: function handleHMACMismatch(item, mayRetry) {
173 this._log.debug("Handling HMAC mismatch for " + item.id);
175 let base = SyncEngine.prototype.handleHMACMismatch.call(this, item, mayRetry);
176 if (base != SyncEngine.kRecoveryStrategy.error)
177 return base;
179 // It's a bad client record. Save it to be deleted at the end of the sync.
180 this._log.debug("Bad client record detected. Scheduling for deletion.");
181 this._deleteId(item.id);
183 // Neither try again nor error; we're going to delete it.
184 return SyncEngine.kRecoveryStrategy.ignore;
185 },
187 /**
188 * A hash of valid commands that the client knows about. The key is a command
189 * and the value is a hash containing information about the command such as
190 * number of arguments and description.
191 */
192 _commands: {
193 resetAll: { args: 0, desc: "Clear temporary local data for all engines" },
194 resetEngine: { args: 1, desc: "Clear temporary local data for engine" },
195 wipeAll: { args: 0, desc: "Delete all client data for all engines" },
196 wipeEngine: { args: 1, desc: "Delete all client data for engine" },
197 logout: { args: 0, desc: "Log out client" },
198 displayURI: { args: 3, desc: "Instruct a client to display a URI" },
199 },
201 /**
202 * Remove any commands for the local client and mark it for upload.
203 */
204 clearCommands: function clearCommands() {
205 delete this.localCommands;
206 this._tracker.addChangedID(this.localID);
207 },
209 /**
210 * Sends a command+args pair to a specific client.
211 *
212 * @param command Command string
213 * @param args Array of arguments/data for command
214 * @param clientId Client to send command to
215 */
216 _sendCommandToClient: function sendCommandToClient(command, args, clientId) {
217 this._log.trace("Sending " + command + " to " + clientId);
219 let client = this._store._remoteClients[clientId];
220 if (!client) {
221 throw new Error("Unknown remote client ID: '" + clientId + "'.");
222 }
224 // notDupe compares two commands and returns if they are not equal.
225 let notDupe = function(other) {
226 return other.command != command || !Utils.deepEquals(other.args, args);
227 };
229 let action = {
230 command: command,
231 args: args,
232 };
234 if (!client.commands) {
235 client.commands = [action];
236 }
237 // Add the new action if there are no duplicates.
238 else if (client.commands.every(notDupe)) {
239 client.commands.push(action);
240 }
241 // It must be a dupe. Skip.
242 else {
243 return;
244 }
246 this._log.trace("Client " + clientId + " got a new action: " + [command, args]);
247 this._tracker.addChangedID(clientId);
248 },
250 /**
251 * Check if the local client has any remote commands and perform them.
252 *
253 * @return false to abort sync
254 */
255 processIncomingCommands: function processIncomingCommands() {
256 return this._notify("clients:process-commands", "", function() {
257 let commands = this.localCommands;
259 // Immediately clear out the commands as we've got them locally.
260 this.clearCommands();
262 // Process each command in order.
263 for each ({command: command, args: args} in commands) {
264 this._log.debug("Processing command: " + command + "(" + args + ")");
266 let engines = [args[0]];
267 switch (command) {
268 case "resetAll":
269 engines = null;
270 // Fallthrough
271 case "resetEngine":
272 this.service.resetClient(engines);
273 break;
274 case "wipeAll":
275 engines = null;
276 // Fallthrough
277 case "wipeEngine":
278 this.service.wipeClient(engines);
279 break;
280 case "logout":
281 this.service.logout();
282 return false;
283 case "displayURI":
284 this._handleDisplayURI.apply(this, args);
285 break;
286 default:
287 this._log.debug("Received an unknown command: " + command);
288 break;
289 }
290 }
292 return true;
293 })();
294 },
296 /**
297 * Validates and sends a command to a client or all clients.
298 *
299 * Calling this does not actually sync the command data to the server. If the
300 * client already has the command/args pair, it won't receive a duplicate
301 * command.
302 *
303 * @param command
304 * Command to invoke on remote clients
305 * @param args
306 * Array of arguments to give to the command
307 * @param clientId
308 * Client ID to send command to. If undefined, send to all remote
309 * clients.
310 */
311 sendCommand: function sendCommand(command, args, clientId) {
312 let commandData = this._commands[command];
313 // Don't send commands that we don't know about.
314 if (!commandData) {
315 this._log.error("Unknown command to send: " + command);
316 return;
317 }
318 // Don't send a command with the wrong number of arguments.
319 else if (!args || args.length != commandData.args) {
320 this._log.error("Expected " + commandData.args + " args for '" +
321 command + "', but got " + args);
322 return;
323 }
325 if (clientId) {
326 this._sendCommandToClient(command, args, clientId);
327 } else {
328 for (let id in this._store._remoteClients) {
329 this._sendCommandToClient(command, args, id);
330 }
331 }
332 },
334 /**
335 * Send a URI to another client for display.
336 *
337 * A side effect is the score is increased dramatically to incur an
338 * immediate sync.
339 *
340 * If an unknown client ID is specified, sendCommand() will throw an
341 * Error object.
342 *
343 * @param uri
344 * URI (as a string) to send and display on the remote client
345 * @param clientId
346 * ID of client to send the command to. If not defined, will be sent
347 * to all remote clients.
348 * @param title
349 * Title of the page being sent.
350 */
351 sendURIToClientForDisplay: function sendURIToClientForDisplay(uri, clientId, title) {
352 this._log.info("Sending URI to client: " + uri + " -> " +
353 clientId + " (" + title + ")");
354 this.sendCommand("displayURI", [uri, this.localID, title], clientId);
356 this._tracker.score += SCORE_INCREMENT_XLARGE;
357 },
359 /**
360 * Handle a single received 'displayURI' command.
361 *
362 * Interested parties should observe the "weave:engine:clients:display-uri"
363 * topic. The callback will receive an object as the subject parameter with
364 * the following keys:
365 *
366 * uri URI (string) that is requested for display.
367 * clientId ID of client that sent the command.
368 * title Title of page that loaded URI (likely) corresponds to.
369 *
370 * The 'data' parameter to the callback will not be defined.
371 *
372 * @param uri
373 * String URI that was received
374 * @param clientId
375 * ID of client that sent URI
376 * @param title
377 * String title of page that URI corresponds to. Older clients may not
378 * send this.
379 */
380 _handleDisplayURI: function _handleDisplayURI(uri, clientId, title) {
381 this._log.info("Received a URI for display: " + uri + " (" + title +
382 ") from " + clientId);
384 let subject = {uri: uri, client: clientId, title: title};
385 Svc.Obs.notify("weave:engine:clients:display-uri", subject);
386 }
387 };
389 function ClientStore(name, engine) {
390 Store.call(this, name, engine);
391 }
392 ClientStore.prototype = {
393 __proto__: Store.prototype,
395 create: function create(record) this.update(record),
397 update: function update(record) {
398 // Only grab commands from the server; local name/type always wins
399 if (record.id == this.engine.localID)
400 this.engine.localCommands = record.commands;
401 else
402 this._remoteClients[record.id] = record.cleartext;
403 },
405 createRecord: function createRecord(id, collection) {
406 let record = new ClientsRec(collection, id);
408 // Package the individual components into a record for the local client
409 if (id == this.engine.localID) {
410 record.name = this.engine.localName;
411 record.type = this.engine.localType;
412 record.commands = this.engine.localCommands;
413 record.version = Services.appinfo.version;
414 record.protocols = SUPPORTED_PROTOCOL_VERSIONS;
415 }
416 else
417 record.cleartext = this._remoteClients[id];
419 return record;
420 },
422 itemExists: function itemExists(id) id in this.getAllIDs(),
424 getAllIDs: function getAllIDs() {
425 let ids = {};
426 ids[this.engine.localID] = true;
427 for (let id in this._remoteClients)
428 ids[id] = true;
429 return ids;
430 },
432 wipe: function wipe() {
433 this._remoteClients = {};
434 },
435 };
437 function ClientsTracker(name, engine) {
438 Tracker.call(this, name, engine);
439 Svc.Obs.add("weave:engine:start-tracking", this);
440 Svc.Obs.add("weave:engine:stop-tracking", this);
441 }
442 ClientsTracker.prototype = {
443 __proto__: Tracker.prototype,
445 _enabled: false,
447 observe: function observe(subject, topic, data) {
448 switch (topic) {
449 case "weave:engine:start-tracking":
450 if (!this._enabled) {
451 Svc.Prefs.observe("client.name", this);
452 this._enabled = true;
453 }
454 break;
455 case "weave:engine:stop-tracking":
456 if (this._enabled) {
457 Svc.Prefs.ignore("clients.name", this);
458 this._enabled = false;
459 }
460 break;
461 case "nsPref:changed":
462 this._log.debug("client.name preference changed");
463 this.addChangedID(Svc.Prefs.get("client.GUID"));
464 this.score += SCORE_INCREMENT_XLARGE;
465 break;
466 }
467 }
468 };