Bug 1539764 - Add a targetFront attribute to the Front class to retrieve the target...
[gecko.git] / devtools / shared / discovery / discovery.js
bloba5f58533b4a900fb16e3f7f88243f1b7a9ecfd5e
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/. */
4 "use strict";
6 /**
7  * This implements a UDP mulitcast device discovery protocol that:
8  *   * Is optimized for mobile devices
9  *   * Doesn't require any special schema for service info
10  *
11  * To ensure it works well on mobile devices, there is no heartbeat or other
12  * recurring transmission.
13  *
14  * Devices are typically in one of two groups: scanning for services or
15  * providing services (though they may be in both groups as well).
16  *
17  * Scanning devices listen on UPDATE_PORT for UDP multicast traffic.  When the
18  * scanning device wants to force an update of the services available, it sends
19  * a status packet to SCAN_PORT.
20  *
21  * Service provider devices listen on SCAN_PORT for any packets from scanning
22  * devices.  If one is recevied, the provider device sends a status packet
23  * (listing the services it offers) to UPDATE_PORT.
24  *
25  * Scanning devices purge any previously known devices after REPLY_TIMEOUT ms
26  * from that start of a scan if no reply is received during the most recent
27  * scan.
28  *
29  * When a service is registered, is supplies a regular object with any details
30  * about itself (a port number, for example) in a service-defined format, which
31  * is then available to scanning devices.
32  */
34 const { Cu, CC, Cc, Ci } = require("chrome");
35 const EventEmitter = require("devtools/shared/event-emitter");
36 const Services = require("Services");
38 const UDPSocket = CC(
39   "@mozilla.org/network/udp-socket;1",
40   "nsIUDPSocket",
41   "init"
44 const SCAN_PORT = 50624;
45 const UPDATE_PORT = 50625;
46 const ADDRESS = "224.0.0.115";
47 const REPLY_TIMEOUT = 5000;
49 const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
51 XPCOMUtils.defineLazyGetter(this, "converter", () => {
52   const conv = Cc[
53     "@mozilla.org/intl/scriptableunicodeconverter"
54   ].createInstance(Ci.nsIScriptableUnicodeConverter);
55   conv.charset = "utf8";
56   return conv;
57 });
59 XPCOMUtils.defineLazyGetter(this, "sysInfo", () => {
60   return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2);
61 });
63 var logging = Services.prefs.getBoolPref("devtools.discovery.log");
64 function log(msg) {
65   if (logging) {
66     console.log("DISCOVERY: " + msg);
67   }
70 /**
71  * Each Transport instance owns a single UDPSocket.
72  * @param port integer
73  *        The port to listen on for incoming UDP multicast packets.
74  */
75 function Transport(port) {
76   EventEmitter.decorate(this);
77   try {
78     this.socket = new UDPSocket(
79       port,
80       false,
81       Services.scriptSecurityManager.getSystemPrincipal()
82     );
83     this.socket.joinMulticast(ADDRESS);
84     this.socket.asyncListen(this);
85   } catch (e) {
86     log("Failed to start new socket: " + e);
87   }
90 Transport.prototype = {
91   /**
92    * Send a object to some UDP port.
93    * @param object object
94    *        Object which is the message to send
95    * @param port integer
96    *        UDP port to send the message to
97    */
98   send: function(object, port) {
99     if (logging) {
100       log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
101     }
102     const message = JSON.stringify(object);
103     const rawMessage = converter.convertToByteArray(message);
104     try {
105       this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
106     } catch (e) {
107       log("Failed to send message: " + e);
108     }
109   },
111   destroy: function() {
112     this.socket.close();
113   },
115   // nsIUDPSocketListener
117   onPacketReceived: function(socket, message) {
118     const messageData = message.data;
119     const object = JSON.parse(messageData);
120     object.from = message.fromAddr.address;
121     const port = message.fromAddr.port;
122     if (port == this.socket.port) {
123       log("Ignoring looped message");
124       return;
125     }
126     if (logging) {
127       log(
128         "Recv on " + this.socket.port + ":\n" + JSON.stringify(object, null, 2)
129       );
130     }
131     this.emit("message", object);
132   },
134   onStopListening: function() {},
138  * Manages the local device's name.  The name can be generated in serveral
139  * platform-specific ways (see |_generate|).  The aim is for each device on the
140  * same local network to have a unique name.
141  */
142 function LocalDevice() {
143   this._name = LocalDevice.UNKNOWN;
144   // Trigger |_get| to load name eagerly
145   this._get();
148 LocalDevice.UNKNOWN = "unknown";
150 LocalDevice.prototype = {
151   _get: function() {
152     // Without Settings API, just generate a name and stop, since the value
153     // can't be persisted.
154     this._generate();
155   },
157   /**
158    * Generate a new device name from various platform-specific properties.
159    * Triggers the |name| setter to persist if needed.
160    */
161   _generate: function() {
162     if (Services.appinfo.widgetToolkit == "android") {
163       // For Firefox for Android, use the device's model name.
164       // TODO: Bug 1180997: Find the right way to expose an editable name
165       this.name = sysInfo.get("device");
166     } else {
167       this.name = Cc["@mozilla.org/network/dns-service;1"].getService(
168         Ci.nsIDNSService
169       ).myHostName;
170     }
171   },
173   get name() {
174     return this._name;
175   },
177   set name(name) {
178     this._name = name;
179     log("Device: " + this._name);
180   },
183 function Discovery() {
184   EventEmitter.decorate(this);
186   this.localServices = {};
187   this.remoteServices = {};
188   this.device = new LocalDevice();
189   this.replyTimeout = REPLY_TIMEOUT;
191   // Defaulted to Transport, but can be altered by tests
192   this._factories = { Transport: Transport };
194   this._transports = {
195     scan: null,
196     update: null,
197   };
198   this._expectingReplies = {
199     from: new Set(),
200   };
202   this._onRemoteScan = this._onRemoteScan.bind(this);
203   this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
204   this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
207 Discovery.prototype = {
208   /**
209    * Add a new service offered by this device.
210    * @param service string
211    *        Name of the service
212    * @param info object
213    *        Arbitrary data about the service to announce to scanning devices
214    */
215   addService: function(service, info) {
216     log("ADDING LOCAL SERVICE");
217     if (Object.keys(this.localServices).length === 0) {
218       this._startListeningForScan();
219     }
220     this.localServices[service] = info;
221   },
223   /**
224    * Remove a service offered by this device.
225    * @param service string
226    *        Name of the service
227    */
228   removeService: function(service) {
229     delete this.localServices[service];
230     if (Object.keys(this.localServices).length === 0) {
231       this._stopListeningForScan();
232     }
233   },
235   /**
236    * Scan for service updates from other devices.
237    */
238   scan: function() {
239     this._startListeningForUpdate();
240     this._waitForReplies();
241     // TODO Bug 1027457: Use timer to debounce
242     this._sendStatusTo(SCAN_PORT);
243   },
245   /**
246    * Get a list of all remote devices currently offering some service.:w
247    */
248   getRemoteDevices: function() {
249     const devices = new Set();
250     for (const service in this.remoteServices) {
251       for (const device in this.remoteServices[service]) {
252         devices.add(device);
253       }
254     }
255     return [...devices];
256   },
258   /**
259    * Get a list of all remote devices currently offering a particular service.
260    */
261   getRemoteDevicesWithService: function(service) {
262     const devicesWithService = this.remoteServices[service] || {};
263     return Object.keys(devicesWithService);
264   },
266   /**
267    * Get service info (any details registered by the remote device) for a given
268    * service on a device.
269    */
270   getRemoteService: function(service, device) {
271     const devicesWithService = this.remoteServices[service] || {};
272     return devicesWithService[device];
273   },
275   _waitForReplies: function() {
276     clearTimeout(this._expectingReplies.timer);
277     this._expectingReplies.from = new Set(this.getRemoteDevices());
278     this._expectingReplies.timer = setTimeout(
279       this._purgeMissingDevices,
280       this.replyTimeout
281     );
282   },
284   get Transport() {
285     return this._factories.Transport;
286   },
288   _startListeningForScan: function() {
289     if (this._transports.scan) {
290       // Already listening
291       return;
292     }
293     log("LISTEN FOR SCAN");
294     this._transports.scan = new this.Transport(SCAN_PORT);
295     this._transports.scan.on("message", this._onRemoteScan);
296   },
298   _stopListeningForScan: function() {
299     if (!this._transports.scan) {
300       // Not listening
301       return;
302     }
303     this._transports.scan.off("message", this._onRemoteScan);
304     this._transports.scan.destroy();
305     this._transports.scan = null;
306   },
308   _startListeningForUpdate: function() {
309     if (this._transports.update) {
310       // Already listening
311       return;
312     }
313     log("LISTEN FOR UPDATE");
314     this._transports.update = new this.Transport(UPDATE_PORT);
315     this._transports.update.on("message", this._onRemoteUpdate);
316   },
318   _stopListeningForUpdate: function() {
319     if (!this._transports.update) {
320       // Not listening
321       return;
322     }
323     this._transports.update.off("message", this._onRemoteUpdate);
324     this._transports.update.destroy();
325     this._transports.update = null;
326   },
328   _restartListening: function() {
329     if (this._transports.scan) {
330       this._stopListeningForScan();
331       this._startListeningForScan();
332     }
333     if (this._transports.update) {
334       this._stopListeningForUpdate();
335       this._startListeningForUpdate();
336     }
337   },
339   /**
340    * When sending message, we can use either transport, so just pick the first
341    * one currently alive.
342    */
343   get _outgoingTransport() {
344     if (this._transports.scan) {
345       return this._transports.scan;
346     }
347     if (this._transports.update) {
348       return this._transports.update;
349     }
350     return null;
351   },
353   _sendStatusTo: function(port) {
354     const status = {
355       device: this.device.name,
356       services: this.localServices,
357     };
358     this._outgoingTransport.send(status, port);
359   },
361   _onRemoteScan: function() {
362     // Send my own status in response
363     log("GOT SCAN REQUEST");
364     this._sendStatusTo(UPDATE_PORT);
365   },
367   _onRemoteUpdate: function(update) {
368     log("GOT REMOTE UPDATE");
370     const remoteDevice = update.device;
371     const remoteHost = update.from;
373     // Record the reply as received so it won't be purged as missing
374     this._expectingReplies.from.delete(remoteDevice);
376     // First, loop over the known services
377     for (const service in this.remoteServices) {
378       const devicesWithService = this.remoteServices[service];
379       const hadServiceForDevice = !!devicesWithService[remoteDevice];
380       const haveServiceForDevice = service in update.services;
381       // If the remote device used to have service, but doesn't any longer, then
382       // it was deleted, so we remove it here.
383       if (hadServiceForDevice && !haveServiceForDevice) {
384         delete devicesWithService[remoteDevice];
385         log("REMOVED " + service + ", DEVICE " + remoteDevice);
386         this.emit(service + "-device-removed", remoteDevice);
387       }
388     }
390     // Second, loop over the services in the received update
391     for (const service in update.services) {
392       // Detect if this is a new device for this service
393       const newDevice =
394         !this.remoteServices[service] ||
395         !this.remoteServices[service][remoteDevice];
397       // Look up the service info we may have received previously from the same
398       // remote device
399       const devicesWithService = this.remoteServices[service] || {};
400       const oldDeviceInfo = devicesWithService[remoteDevice];
402       // Store the service info from the remote device
403       const newDeviceInfo = Cu.cloneInto(update.services[service], {});
404       newDeviceInfo.host = remoteHost;
405       devicesWithService[remoteDevice] = newDeviceInfo;
406       this.remoteServices[service] = devicesWithService;
408       // If this is a new service for the remote device, announce the addition
409       if (newDevice) {
410         log("ADDED " + service + ", DEVICE " + remoteDevice);
411         this.emit(service + "-device-added", remoteDevice, newDeviceInfo);
412       }
414       // If we've seen this service from the remote device, but the details have
415       // changed, announce the update
416       if (
417         !newDevice &&
418         JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)
419       ) {
420         log("UPDATED " + service + ", DEVICE " + remoteDevice);
421         this.emit(service + "-device-updated", remoteDevice, newDeviceInfo);
422       }
423     }
424   },
426   _purgeMissingDevices: function() {
427     log("PURGING MISSING DEVICES");
428     for (const service in this.remoteServices) {
429       const devicesWithService = this.remoteServices[service];
430       for (const remoteDevice in devicesWithService) {
431         // If we're still expecting a reply from a remote device when it's time
432         // to purge, then the service is removed.
433         if (this._expectingReplies.from.has(remoteDevice)) {
434           delete devicesWithService[remoteDevice];
435           log("REMOVED " + service + ", DEVICE " + remoteDevice);
436           this.emit(service + "-device-removed", remoteDevice);
437         }
438       }
439     }
440   },
443 var discovery = new Discovery();
445 module.exports = discovery;