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/. */
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
11 * To ensure it works well on mobile devices, there is no heartbeat or other
12 * recurring transmission.
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).
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.
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.
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
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.
34 const { Cu, CC, Cc, Ci } = require("chrome");
35 const EventEmitter = require("devtools/shared/event-emitter");
36 const Services = require("Services");
39 "@mozilla.org/network/udp-socket;1",
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", () => {
53 "@mozilla.org/intl/scriptableunicodeconverter"
54 ].createInstance(Ci.nsIScriptableUnicodeConverter);
55 conv.charset = "utf8";
59 XPCOMUtils.defineLazyGetter(this, "sysInfo", () => {
60 return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2);
63 var logging = Services.prefs.getBoolPref("devtools.discovery.log");
66 console.log("DISCOVERY: " + msg);
71 * Each Transport instance owns a single UDPSocket.
73 * The port to listen on for incoming UDP multicast packets.
75 function Transport(port) {
76 EventEmitter.decorate(this);
78 this.socket = new UDPSocket(
81 Services.scriptSecurityManager.getSystemPrincipal()
83 this.socket.joinMulticast(ADDRESS);
84 this.socket.asyncListen(this);
86 log("Failed to start new socket: " + e);
90 Transport.prototype = {
92 * Send a object to some UDP port.
93 * @param object object
94 * Object which is the message to send
96 * UDP port to send the message to
98 send: function(object, port) {
100 log("Send to " + port + ":\n" + JSON.stringify(object, null, 2));
102 const message = JSON.stringify(object);
103 const rawMessage = converter.convertToByteArray(message);
105 this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
107 log("Failed to send message: " + e);
111 destroy: function() {
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");
128 "Recv on " + this.socket.port + ":\n" + JSON.stringify(object, null, 2)
131 this.emit("message", object);
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.
142 function LocalDevice() {
143 this._name = LocalDevice.UNKNOWN;
144 // Trigger |_get| to load name eagerly
148 LocalDevice.UNKNOWN = "unknown";
150 LocalDevice.prototype = {
152 // Without Settings API, just generate a name and stop, since the value
153 // can't be persisted.
158 * Generate a new device name from various platform-specific properties.
159 * Triggers the |name| setter to persist if needed.
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");
167 this.name = Cc["@mozilla.org/network/dns-service;1"].getService(
179 log("Device: " + this._name);
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 };
198 this._expectingReplies = {
202 this._onRemoteScan = this._onRemoteScan.bind(this);
203 this._onRemoteUpdate = this._onRemoteUpdate.bind(this);
204 this._purgeMissingDevices = this._purgeMissingDevices.bind(this);
207 Discovery.prototype = {
209 * Add a new service offered by this device.
210 * @param service string
211 * Name of the service
213 * Arbitrary data about the service to announce to scanning devices
215 addService: function(service, info) {
216 log("ADDING LOCAL SERVICE");
217 if (Object.keys(this.localServices).length === 0) {
218 this._startListeningForScan();
220 this.localServices[service] = info;
224 * Remove a service offered by this device.
225 * @param service string
226 * Name of the service
228 removeService: function(service) {
229 delete this.localServices[service];
230 if (Object.keys(this.localServices).length === 0) {
231 this._stopListeningForScan();
236 * Scan for service updates from other devices.
239 this._startListeningForUpdate();
240 this._waitForReplies();
241 // TODO Bug 1027457: Use timer to debounce
242 this._sendStatusTo(SCAN_PORT);
246 * Get a list of all remote devices currently offering some service.:w
248 getRemoteDevices: function() {
249 const devices = new Set();
250 for (const service in this.remoteServices) {
251 for (const device in this.remoteServices[service]) {
259 * Get a list of all remote devices currently offering a particular service.
261 getRemoteDevicesWithService: function(service) {
262 const devicesWithService = this.remoteServices[service] || {};
263 return Object.keys(devicesWithService);
267 * Get service info (any details registered by the remote device) for a given
268 * service on a device.
270 getRemoteService: function(service, device) {
271 const devicesWithService = this.remoteServices[service] || {};
272 return devicesWithService[device];
275 _waitForReplies: function() {
276 clearTimeout(this._expectingReplies.timer);
277 this._expectingReplies.from = new Set(this.getRemoteDevices());
278 this._expectingReplies.timer = setTimeout(
279 this._purgeMissingDevices,
285 return this._factories.Transport;
288 _startListeningForScan: function() {
289 if (this._transports.scan) {
293 log("LISTEN FOR SCAN");
294 this._transports.scan = new this.Transport(SCAN_PORT);
295 this._transports.scan.on("message", this._onRemoteScan);
298 _stopListeningForScan: function() {
299 if (!this._transports.scan) {
303 this._transports.scan.off("message", this._onRemoteScan);
304 this._transports.scan.destroy();
305 this._transports.scan = null;
308 _startListeningForUpdate: function() {
309 if (this._transports.update) {
313 log("LISTEN FOR UPDATE");
314 this._transports.update = new this.Transport(UPDATE_PORT);
315 this._transports.update.on("message", this._onRemoteUpdate);
318 _stopListeningForUpdate: function() {
319 if (!this._transports.update) {
323 this._transports.update.off("message", this._onRemoteUpdate);
324 this._transports.update.destroy();
325 this._transports.update = null;
328 _restartListening: function() {
329 if (this._transports.scan) {
330 this._stopListeningForScan();
331 this._startListeningForScan();
333 if (this._transports.update) {
334 this._stopListeningForUpdate();
335 this._startListeningForUpdate();
340 * When sending message, we can use either transport, so just pick the first
341 * one currently alive.
343 get _outgoingTransport() {
344 if (this._transports.scan) {
345 return this._transports.scan;
347 if (this._transports.update) {
348 return this._transports.update;
353 _sendStatusTo: function(port) {
355 device: this.device.name,
356 services: this.localServices,
358 this._outgoingTransport.send(status, port);
361 _onRemoteScan: function() {
362 // Send my own status in response
363 log("GOT SCAN REQUEST");
364 this._sendStatusTo(UPDATE_PORT);
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);
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
394 !this.remoteServices[service] ||
395 !this.remoteServices[service][remoteDevice];
397 // Look up the service info we may have received previously from the same
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
410 log("ADDED " + service + ", DEVICE " + remoteDevice);
411 this.emit(service + "-device-added", remoteDevice, newDeviceInfo);
414 // If we've seen this service from the remote device, but the details have
415 // changed, announce the update
418 JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)
420 log("UPDATED " + service + ", DEVICE " + remoteDevice);
421 this.emit(service + "-device-updated", remoteDevice, newDeviceInfo);
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);
443 var discovery = new Discovery();
445 module.exports = discovery;