discovery.js (12372B)
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"; 5 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 */ 33 34 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 35 36 const UDPSocket = Components.Constructor( 37 "@mozilla.org/network/udp-socket;1", 38 "nsIUDPSocket", 39 "init" 40 ); 41 42 const SCAN_PORT = 50624; 43 const UPDATE_PORT = 50625; 44 const ADDRESS = "224.0.0.115"; 45 const REPLY_TIMEOUT = 5000; 46 47 var logging = Services.prefs.getBoolPref("devtools.discovery.log"); 48 function log(msg) { 49 if (logging) { 50 console.log("DISCOVERY: " + msg); 51 } 52 } 53 54 /** 55 * Each Transport instance owns a single UDPSocket. 56 */ 57 class Transport extends EventEmitter { 58 /** 59 * @param {Integer} port 60 * The port to listen on for incoming UDP multicast packets. 61 */ 62 constructor(port) { 63 super(); 64 65 try { 66 this.socket = new UDPSocket( 67 port, 68 false, 69 Services.scriptSecurityManager.getSystemPrincipal() 70 ); 71 this.socket.joinMulticast(ADDRESS); 72 this.socket.asyncListen(this); 73 } catch (e) { 74 log("Failed to start new socket: " + e); 75 } 76 } 77 /** 78 * Send a object to some UDP port. 79 * 80 * @param {object} object 81 * Object which is the message to send 82 * @param {Integer} port 83 * UDP port to send the message to 84 */ 85 send(object, port) { 86 if (logging) { 87 log("Send to " + port + ":\n" + JSON.stringify(object, null, 2)); 88 } 89 const message = JSON.stringify(object); 90 const rawMessage = Uint8Array.from(message, x => x.charCodeAt(0)); 91 try { 92 this.socket.send(ADDRESS, port, rawMessage, rawMessage.length); 93 } catch (e) { 94 log("Failed to send message: " + e); 95 } 96 } 97 98 destroy() { 99 this.socket.close(); 100 } 101 102 // nsIUDPSocketListener 103 onPacketReceived(socket, message) { 104 const messageData = message.data; 105 const object = JSON.parse(messageData); 106 object.from = message.fromAddr.address; 107 const port = message.fromAddr.port; 108 if (port == this.socket.port) { 109 log("Ignoring looped message"); 110 return; 111 } 112 if (logging) { 113 log( 114 "Recv on " + this.socket.port + ":\n" + JSON.stringify(object, null, 2) 115 ); 116 } 117 this.emit("message", object); 118 } 119 120 onStopListening() {} 121 } 122 123 /** 124 * Manages the local device's name. The name can be generated in serveral 125 * platform-specific ways (see |_generate|). The aim is for each device on the 126 * same local network to have a unique name. 127 */ 128 class LocalDevice { 129 static UNKNOWN = "unknown"; 130 constructor() { 131 this._name = LocalDevice.UNKNOWN; 132 // Trigger |_get| to load name eagerly 133 this._get(); 134 } 135 _get() { 136 // Without Settings API, just generate a name and stop, since the value 137 // can't be persisted. 138 this._generate(); 139 } 140 141 /** 142 * Generate a new device name from various platform-specific properties. 143 * Triggers the |name| setter to persist if needed. 144 */ 145 _generate() { 146 if (Services.appinfo.widgetToolkit == "android") { 147 // For Firefox for Android, use the device's model name. 148 // TODO: Bug 1180997: Find the right way to expose an editable name 149 this.name = Services.sysinfo.get("device"); 150 } else { 151 this.name = Services.dns.myHostName; 152 } 153 } 154 155 get name() { 156 return this._name; 157 } 158 159 set name(name) { 160 this._name = name; 161 log("Device: " + this._name); 162 } 163 } 164 165 class Discovery extends EventEmitter { 166 constructor() { 167 super(); 168 169 this.localServices = {}; 170 this.remoteServices = {}; 171 this.device = new LocalDevice(); 172 this.replyTimeout = REPLY_TIMEOUT; 173 174 // Defaulted to Transport, but can be altered by tests 175 this._factories = { Transport }; 176 177 this._transports = { 178 scan: null, 179 update: null, 180 }; 181 this._expectingReplies = { 182 from: new Set(), 183 }; 184 185 this._onRemoteScan = this._onRemoteScan.bind(this); 186 this._onRemoteUpdate = this._onRemoteUpdate.bind(this); 187 this._purgeMissingDevices = this._purgeMissingDevices.bind(this); 188 } 189 /** 190 * Add a new service offered by this device. 191 * 192 * @param {string} service 193 * Name of the service 194 * @param {object} info 195 * Arbitrary data about the service to announce to scanning devices 196 */ 197 addService(service, info) { 198 log("ADDING LOCAL SERVICE"); 199 if (Object.keys(this.localServices).length === 0) { 200 this._startListeningForScan(); 201 } 202 this.localServices[service] = info; 203 } 204 205 /** 206 * Remove a service offered by this device. 207 * 208 * @param {string} service 209 * Name of the service 210 */ 211 removeService(service) { 212 delete this.localServices[service]; 213 if (Object.keys(this.localServices).length === 0) { 214 this._stopListeningForScan(); 215 } 216 } 217 218 /** 219 * Scan for service updates from other devices. 220 */ 221 scan() { 222 this._startListeningForUpdate(); 223 this._waitForReplies(); 224 // TODO Bug 1027457: Use timer to debounce 225 this._sendStatusTo(SCAN_PORT); 226 } 227 228 /** 229 * Get a list of all remote devices currently offering some service. 230 */ 231 getRemoteDevices() { 232 const devices = new Set(); 233 for (const service in this.remoteServices) { 234 for (const device in this.remoteServices[service]) { 235 devices.add(device); 236 } 237 } 238 return [...devices]; 239 } 240 241 /** 242 * Get a list of all remote devices currently offering a particular service. 243 */ 244 getRemoteDevicesWithService(service) { 245 const devicesWithService = this.remoteServices[service] || {}; 246 return Object.keys(devicesWithService); 247 } 248 249 /** 250 * Get service info (any details registered by the remote device) for a given 251 * service on a device. 252 */ 253 getRemoteService(service, device) { 254 const devicesWithService = this.remoteServices[service] || {}; 255 return devicesWithService[device]; 256 } 257 258 _waitForReplies() { 259 clearTimeout(this._expectingReplies.timer); 260 this._expectingReplies.from = new Set(this.getRemoteDevices()); 261 this._expectingReplies.timer = setTimeout( 262 this._purgeMissingDevices, 263 this.replyTimeout 264 ); 265 } 266 267 get Transport() { 268 return this._factories.Transport; 269 } 270 271 _startListeningForScan() { 272 if (this._transports.scan) { 273 // Already listening 274 return; 275 } 276 log("LISTEN FOR SCAN"); 277 this._transports.scan = new this.Transport(SCAN_PORT); 278 this._transports.scan.on("message", this._onRemoteScan); 279 } 280 281 _stopListeningForScan() { 282 if (!this._transports.scan) { 283 // Not listening 284 return; 285 } 286 this._transports.scan.off("message", this._onRemoteScan); 287 this._transports.scan.destroy(); 288 this._transports.scan = null; 289 } 290 291 _startListeningForUpdate() { 292 if (this._transports.update) { 293 // Already listening 294 return; 295 } 296 log("LISTEN FOR UPDATE"); 297 this._transports.update = new this.Transport(UPDATE_PORT); 298 this._transports.update.on("message", this._onRemoteUpdate); 299 } 300 301 _stopListeningForUpdate() { 302 if (!this._transports.update) { 303 // Not listening 304 return; 305 } 306 this._transports.update.off("message", this._onRemoteUpdate); 307 this._transports.update.destroy(); 308 this._transports.update = null; 309 } 310 311 _restartListening() { 312 if (this._transports.scan) { 313 this._stopListeningForScan(); 314 this._startListeningForScan(); 315 } 316 if (this._transports.update) { 317 this._stopListeningForUpdate(); 318 this._startListeningForUpdate(); 319 } 320 } 321 322 /** 323 * When sending message, we can use either transport, so just pick the first 324 * one currently alive. 325 */ 326 get _outgoingTransport() { 327 if (this._transports.scan) { 328 return this._transports.scan; 329 } 330 if (this._transports.update) { 331 return this._transports.update; 332 } 333 return null; 334 } 335 336 _sendStatusTo(port) { 337 const status = { 338 device: this.device.name, 339 services: this.localServices, 340 }; 341 this._outgoingTransport.send(status, port); 342 } 343 344 _onRemoteScan() { 345 // Send my own status in response 346 log("GOT SCAN REQUEST"); 347 this._sendStatusTo(UPDATE_PORT); 348 } 349 350 _onRemoteUpdate(update) { 351 log("GOT REMOTE UPDATE"); 352 353 const remoteDevice = update.device; 354 const remoteHost = update.from; 355 356 // Record the reply as received so it won't be purged as missing 357 this._expectingReplies.from.delete(remoteDevice); 358 359 // First, loop over the known services 360 for (const service in this.remoteServices) { 361 const devicesWithService = this.remoteServices[service]; 362 const hadServiceForDevice = !!devicesWithService[remoteDevice]; 363 const haveServiceForDevice = service in update.services; 364 // If the remote device used to have service, but doesn't any longer, then 365 // it was deleted, so we remove it here. 366 if (hadServiceForDevice && !haveServiceForDevice) { 367 delete devicesWithService[remoteDevice]; 368 log("REMOVED " + service + ", DEVICE " + remoteDevice); 369 this.emit(service + "-device-removed", remoteDevice); 370 } 371 } 372 373 // Second, loop over the services in the received update 374 for (const service in update.services) { 375 // Detect if this is a new device for this service 376 const newDevice = 377 !this.remoteServices[service] || 378 !this.remoteServices[service][remoteDevice]; 379 380 // Look up the service info we may have received previously from the same 381 // remote device 382 const devicesWithService = this.remoteServices[service] || {}; 383 const oldDeviceInfo = devicesWithService[remoteDevice]; 384 385 // Store the service info from the remote device 386 const newDeviceInfo = Cu.cloneInto(update.services[service], {}); 387 newDeviceInfo.host = remoteHost; 388 devicesWithService[remoteDevice] = newDeviceInfo; 389 this.remoteServices[service] = devicesWithService; 390 391 // If this is a new service for the remote device, announce the addition 392 if (newDevice) { 393 log("ADDED " + service + ", DEVICE " + remoteDevice); 394 this.emit(service + "-device-added", remoteDevice, newDeviceInfo); 395 } 396 397 // If we've seen this service from the remote device, but the details have 398 // changed, announce the update 399 if ( 400 !newDevice && 401 JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo) 402 ) { 403 log("UPDATED " + service + ", DEVICE " + remoteDevice); 404 this.emit(service + "-device-updated", remoteDevice, newDeviceInfo); 405 } 406 } 407 } 408 409 _purgeMissingDevices() { 410 log("PURGING MISSING DEVICES"); 411 for (const service in this.remoteServices) { 412 const devicesWithService = this.remoteServices[service]; 413 for (const remoteDevice in devicesWithService) { 414 // If we're still expecting a reply from a remote device when it's time 415 // to purge, then the service is removed. 416 if (this._expectingReplies.from.has(remoteDevice)) { 417 delete devicesWithService[remoteDevice]; 418 log("REMOVED " + service + ", DEVICE " + remoteDevice); 419 this.emit(service + "-device-removed", remoteDevice); 420 } 421 } 422 } 423 } 424 } 425 426 var discovery = new Discovery(); 427 428 module.exports = discovery;