diff --git a/index.html b/index.html index f7269a6..5cd081a 100644 --- a/index.html +++ b/index.html @@ -77,6 +77,8 @@ + + Pairing diff --git a/libgamestream/pairing.c b/libgamestream/pairing.c index 1a795ee..7ecb31a 100644 --- a/libgamestream/pairing.c +++ b/libgamestream/pairing.c @@ -233,10 +233,6 @@ int gs_pair(int serverMajorVersion, const char* address, const char* pin) { if ((ret = http_request(url, data)) != GS_OK) goto cleanup; - sprintf(url, "https://%s:47984/pair?uniqueid=%s&devicename=roth&updateState=1&phrase=pairchallenge", address, g_UniqueId); - if ((ret = http_request(url, data)) != GS_OK) - goto cleanup; - cleanup: http_free_data(data); diff --git a/main.cpp b/main.cpp index 5318fc5..de1e3df 100644 --- a/main.cpp +++ b/main.cpp @@ -227,7 +227,7 @@ void MoonlightInstance::PairCallback(int32_t /*result*/, int32_t callbackId, pp: pp::VarDictionary ret; ret.Set("callbackId", pp::Var(callbackId)); - ret.Set("type", err ? pp::Var("reject") : pp::Var("resolve")); + ret.Set("type", pp::Var("resolve")); ret.Set("ret", pp::Var(err)); PostMessage(ret); } diff --git a/manifest.json b/manifest.json index 69b3145..fe68d00 100644 --- a/manifest.json +++ b/manifest.json @@ -16,9 +16,13 @@ "scripts": ["static/js/jquery-2.2.0.min.js", "static/js/material.min.js", "static/js/common.js", "static/js/background.js"] } }, + "sockets": { + "udp": { "bind": "*", "send": "*" } + }, "permissions": [ "storage", "pointerLock", + "system.network", "fullscreen", { "socket": [ "tcp-connect", diff --git a/static/js/index.js b/static/js/index.js index d9f17db..b260457 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -31,7 +31,7 @@ function snackbarLog(givenMessage) { } function updateBitrateField() { - $('#bitrateField').html($('#bitrateSlider')[0].value + " Mbps"); + $('#bitrateField').html($('#bitrateSlider').val() + " Mbps"); } function moduleDidLoad() { @@ -60,10 +60,13 @@ function moduleDidLoad() { // because the user can change the target host at any time, we continually have to check function updateTarget() { - target = $('#GFEHostIPField')[0].value; + target = $('#GFEHostIPField').val(); if (target == null || target == "") { - var e = $("#selectHost")[0]; - target = e.options[e.selectedIndex].value; + target = $("#selectHost option:selected").val(); + } + + if(api && api.address != target) { + api = new NvHTTP(target, myUniqueid); } } @@ -80,70 +83,57 @@ function hideAllWorkflowDivs() { // pair button was pushed. pass what the user entered into the GFEHostIPField. function pairPushed() { updateTarget(); + if(!pairingCert) { snackbarLog('ERROR: cert has not been generated yet. Is NaCL initialized?'); console.log("User wants to pair, and we still have no cert. Problem = very yes."); return; } - if(!api || !api.paired) { + + if(!api) { api = new NvHTTP(target, myUniqueid); } - - - api.refreshServerInfoUnpaired().then(function (ret) { - - if(api.currentGame != 0) { // make sure host isn't already in a game - snackbarLog(target + ' is already in game. Cannot pair!'); + + if(api.paired) { + return; + } + + $('#pairButton').html('Pairing...'); + snackbarLog('Attempting pair to: ' + target); + var randomNumber = String("0000" + (Math.random()*10000|0)).slice(-4); + var pairingDialog = document.querySelector('#pairingDialog'); + $('#pairingDialogText').html('Please enter the number ' + randomNumber + ' on the GFE dialog on the computer. This dialog will be dismissed once complete'); + pairingDialog.showModal(); + console.log('sending pairing request to ' + target + ' with random number ' + randomNumber); + + api.pair(randomNumber).then(function (paired) { + if (!paired) { + snackbarLog('Pairing failed'); + $('#pairButton').html('Pairing Failed'); + $('#pairingDialogText').html('Error: Pairing failed'); + if (api.currentGame != 0) + snackbarLog(target + ' is already in game. Cannot pair!'); return; } + + $('#pairButton').html('Paired'); + snackbarLog('Pairing successful'); + pairingDialog.close(); + + var hostSelect = $('#selectHost')[0]; + for(var i = 0; i < hostSelect.length; i++) { // check if we already have the host. + if (hostSelect.options[i].value == target) return; + } - api.refreshServerInfo().then(function (ret) { - if(api.paired) { // don't pair if you don't have to - snackbarLog('already paired with ' + target + '!'); - return; - } else { // we're not already paired. pair now. - - $('#pairButton')[0].innerHTML = 'Pairing...'; - snackbarLog('Attempting pair to: ' + target); - var randomNumber = String("0000" + (Math.random()*10000|0)).slice(-4); - var pairingDialog = document.querySelector('#pairingDialog'); - document.getElementById('pairingDialogText').innerHTML = - 'Please enter the number ' + randomNumber + ' on the GFE dialog on the computer. This dialog will be dismissed once complete'; - pairingDialog.showModal(); - console.log('sending pairing request to ' + target + ' with random number ' + randomNumber); - - sendMessage('pair', [api.serverMajorVersion, target, randomNumber]).then(function (ret3) { - console.log('"pair" call returned.'); - console.log(ret3); - if (ret3 === 0) { // pairing was successful. save this host. - $('#pairButton')[0].innerHTML = 'Paired'; - snackbarLog('Pairing successful'); - pairingDialog.close(); - var hostSelect = $('#selectHost')[0]; - for(var i = 0; i < hostSelect.length; i++) { // check if we already have the host. - if (hostSelect.options[i].value == target) return; - } - - var opt = document.createElement('option'); - opt.appendChild(document.createTextNode(target)); - opt.value = target; - $('#selectHost')[0].appendChild(opt); - hosts.push(target); - saveHosts(); - // move directly on to retrieving the apps list. - showAppsPushed(); - } else { - snackbarLog('Pairing failed'); - $('#pairButton')[0].innerHTML = 'Pairing Failed'; - document.getElementById('pairingDialogText').innerHTML = 'Error: Pairing failed with code: ' + ret3; - } - console.log("pairing attempt returned: " + ret3); - }); - - } - }); + var opt = document.createElement('option'); + opt.appendChild(document.createTextNode(target)); + opt.value = target; + $('#selectHost')[0].appendChild(opt); + hosts.push(target); + saveHosts(); + + showAppsPushed(); }); - } function pairingPopupCanceled() { @@ -155,30 +145,32 @@ function pairingPopupCanceled() { // otherwise, we assume they selected from the host history dropdown. function showAppsPushed() { updateTarget(); + if(!api || !api.paired) { - api = new NvHTTP(target, myUniqueid); + return; } - api.refreshServerInfo().then(function (ret) { - api.getAppList().then(function (appList) { - if ($('#selectGame').has('option').length > 0 ) { - // there was already things in the dropdown. Clear it, then add the new ones. - // Most likely, the user just hit the 'retrieve app list' button again - $('#selectGame').empty(); - } - for(var i = 0; i < appList.length; i++) { // programmatically add each app - var opt = document.createElement('option'); - opt.appendChild(document.createTextNode(appList[i])); - opt.value = appList[i].id; - opt.innerHTML = appList[i].title; - $('#selectGame')[0].appendChild(opt); - } - $("#selectGame").html($("#selectGame option").sort(function (a, b) { // thanks, http://stackoverflow.com/a/7466196/3006365 - return a.text.toUpperCase() == b.text.toUpperCase() ? 0 : a.text.toUpperCase() < b.text.toUpperCase() ? -1 : 1 - })); - if (api.currentGame != 0) $('#selectGame')[0].value = api.currentGame; - gameSelectUpdated(); // default the button to 'Resume Game' if one is running. + + api.getAppList().then(function (appList) { + if ($('#selectGame').has('option').length > 0 ) { + // there was already things in the dropdown. Clear it, then add the new ones. + // Most likely, the user just hit the 'retrieve app list' button again + $('#selectGame').empty(); + } + + appList.forEach(function (app) { + $('#selectGame').append($('', {value: app.id, text: app.title})); }); + + $("#selectGame").html($("#selectGame option").sort(function (a, b) { // thanks, http://stackoverflow.com/a/7466196/3006365 + return a.text.toUpperCase() == b.text.toUpperCase() ? 0 : a.text.toUpperCase() < b.text.toUpperCase() ? -1 : 1 + })); + + if (api.currentGame != 0) + $('#selectGame').val(api.currentGame); + + gameSelectUpdated(); // default the button to 'Resume Game' if one is running. }); + showAppsMode(); } @@ -199,9 +191,9 @@ function showAppsMode() { function gameSelectUpdated() { var currentApp = $("#selectGame").val(); if(api.currentGame == parseInt(currentApp)) { - $("#startGameButton")[0].innerHTML = 'Resume Game' + $("#startGameButton").html('Resume Game'); } else { - $("#startGameButton")[0].innerHTML = 'Run Game' + $("#startGameButton").html('Run Game'); } } @@ -211,9 +203,11 @@ function startSelectedGame() { // if we need to reconnect to the target, and `target` has been updated, we can pass the appID we listed from the previous target // then everyone's sad. So we won't do that. Because the only way to see the startGame button is to list the apps for the target anyways. if(!api || !api.paired) { - api = new NvHTTP(target, myUniqueid); + return; } + var appID = $("#selectGame")[0].options[$("#selectGame")[0].selectedIndex].value; // app that the user wants to play + // refresh the server info, because the user might have quit the game. api.refreshServerInfo().then(function (ret) { if(api.currentGame != 0 && api.currentGame != appID) { @@ -230,9 +224,9 @@ function startSelectedGame() { console.log('finished api being in another game. returning out of startSelectedGame'); return; } - api.getAppById(appID).then(function (requestedApp) { - snackbarLog('Starting app: ' + requestedApp.title); - }); + + snackbarLog('Starting app: ' + $("#selectGame")[0].options[$("#selectGame")[0].selectedIndex].innerHTML); + var frameRate = $("#selectFramerate").val(); var streamWidth = $('#selectResolution option:selected').val().split(':')[0]; var streamHeight = $('#selectResolution option:selected').val().split(':')[1]; @@ -297,8 +291,8 @@ function fullscreenNaclModule() { var zoom = Math.min(xRatio, yRatio); var module = $("#nacl_module")[0]; - module.width=zoom * streamWidth; - module.height=zoom * streamHeight; + module.width = zoom * streamWidth; + module.height = zoom * streamHeight; module.style.paddingTop = ((screenHeight - module.height) / 2) + "px"; } @@ -376,12 +370,12 @@ function onWindowLoad(){ // load stored resolution prefs chrome.storage.sync.get('resolution', function(previousValue) { $('#selectResolution')[0].remove(0); - $('#selectResolution')[0].value = previousValue.resolution != null ? previousValue.resolution : '1280:720'; + $('#selectResolution').val(previousValue.resolution != null ? previousValue.resolution : '1280:720'); }); // load stored framerate prefs chrome.storage.sync.get('frameRate', function(previousValue) { $('#selectFramerate')[0].remove(0); - $('#selectFramerate')[0].value = previousValue.frameRate != null ? previousValue.frameRate : '30'; + $('#selectFramerate').val(previousValue.frameRate != null ? previousValue.frameRate : '30'); }); // load previously connected hosts chrome.storage.sync.get('hosts', function(previousValue) { @@ -413,6 +407,16 @@ function onWindowLoad(){ } }); } + + findNvService(function (finder, opt_error) { + if (finder.byService_['_nvstream._tcp']) { + var ip = Object.keys(finder.byService_['_nvstream._tcp'])[0]; + if (finder.byService_['_nvstream._tcp'][ip]) { + $('#GFEHostIPField').val(ip); + updateTarget(); + } + } + }); } diff --git a/static/js/mdns-browser/dns.js b/static/js/mdns-browser/dns.js new file mode 100644 index 0000000..6e0cf69 --- /dev/null +++ b/static/js/mdns-browser/dns.js @@ -0,0 +1,246 @@ + +/** + * DataWriter writes data to an ArrayBuffer, presenting it as the instance + * variable 'buffer'. + * + * @constructor + */ +var DataWriter = function(opt_size) { + var loc = 0; + var view = new Uint8Array(new ArrayBuffer(opt_size || 512)); + + this.byte_ = function(v) { + view[loc] = v; + ++loc; + this.buffer = view.buffer.slice(0, loc); + }.bind(this); +}; + +DataWriter.prototype.byte = function(v) { + this.byte_(v); + return this; +}; + +DataWriter.prototype.short = function(v) { + return this.byte((v >> 8) & 0xff).byte(v & 0xff); +}; + +DataWriter.prototype.long = function(v) { + return this.short((v >> 16) & 0xffff).short(v & 0xffff); +}; + +/** + * Writes a DNS name. If opt_ref is specified, will finish this name with a + * suffix reference (i.e., 0xc0 ). If not, then will terminate with a NULL + * byte. + */ +DataWriter.prototype.name = function(v, opt_ref) { + var parts = v.split('.'); + parts.forEach(function(part) { + this.byte(part.length); + for (var i = 0; i < part.length; ++i) { + this.byte(part.charCodeAt(i)); + } + }.bind(this)); + if (opt_ref) { + this.byte(0xc0).byte(opt_ref); + } else { + this.byte(0); + } + return this; +}; + +/** + * DataConsumer consumes data from an ArrayBuffer. + * + * @constructor + */ +var DataConsumer = function(arg) { + if (arg instanceof Uint8Array) { + this.view_ = arg; + } else { + this.view_ = new Uint8Array(arg); + } + this.loc_ = 0; +}; + +/** + * @return whether this DataConsumer has consumed all its data + */ +DataConsumer.prototype.isEOF = function() { + return this.loc_ >= this.view_.byteLength; +}; + +/** + * @param length {integer} number of bytes to return from the front of the view + * @return a Uint8Array + */ +DataConsumer.prototype.slice = function(length) { + var view = this.view_.subarray(this.loc_, this.loc_ + length); + this.loc_ += length; + return view; +}; + +DataConsumer.prototype.byte = function() { + this.loc_ += 1; + return this.view_[this.loc_ - 1]; +}; + +DataConsumer.prototype.short = function() { + return (this.byte() << 8) + this.byte(); +}; + +DataConsumer.prototype.long = function() { + return (this.short() << 16) + this.short(); +}; + +/** + * Consumes a DNS name, which will either finish with a NULL byte or a suffix + * reference (i.e., 0xc0 ). + */ +DataConsumer.prototype.name = function() { + var parts = []; + for (;;) { + var len = this.byte(); + if (!len) { + break; + } else if (len == 0xc0) { + // TODO: This indicates a pointer to another valid name inside the + // DNSPacket, and is always a suffix: we're at the end of the name. + // We should probably hold onto this value instead of discarding it. + var ref = this.byte(); + break; + } + + // Otherwise, consume a string! + var v = ''; + while (len-- > 0) { + v += String.fromCharCode(this.byte()); + } + parts.push(v); + } + return parts.join('.'); +}; + +/** + * DNSPacket holds the state of a DNS packet. It can be modified or serialized + * in-place. + * + * @constructor + */ +var DNSPacket = function(opt_flags) { + this.flags_ = opt_flags || 0; /* uint16 */ + this.data_ = {'qd': [], 'an': [], 'ns': [], 'ar': []}; +}; + +/** + * Parse a DNSPacket from an ArrayBuffer (or Uint8Array). + */ +DNSPacket.parse = function(buffer) { + var consumer = new DataConsumer(buffer); + if (consumer.short()) { + throw new Error('DNS packet must start with 00 00'); + } + var flags = consumer.short(); + var count = { + 'qd': consumer.short(), + 'an': consumer.short(), + 'ns': consumer.short(), + 'ar': consumer.short(), + }; + var packet = new DNSPacket(flags); + + // Parse the QUESTION section. + for (var i = 0; i < count['qd']; ++i) { + var part = new DNSRecord( + consumer.name(), + consumer.short(), // type + consumer.short()); // class + packet.push('qd', part); + } + + // Parse the ANSWER, AUTHORITY and ADDITIONAL sections. + ['an', 'ns', 'ar'].forEach(function(section) { + for (var i = 0; i < count[section]; ++i) { + var part = new DNSRecord( + consumer.name(), + consumer.short(), // type + consumer.short(), // class + consumer.long(), // ttl + consumer.slice(consumer.short())); + packet.push(section, part); + } + }); + + consumer.isEOF() || console.warn('was not EOF on incoming packet'); + return packet; +}; + +DNSPacket.prototype.push = function(section, record) { + this.data_[section].push(record); +}; + +DNSPacket.prototype.each = function(section) { + var filter = false; + var call; + if (arguments.length == 2) { + call = arguments[1]; + } else { + filter = arguments[1]; + call = arguments[2]; + } + this.data_[section].forEach(function(rec) { + if (!filter || rec.type == filter) { + call(rec); + } + }); +}; + +/** + * Serialize this DNSPacket into an ArrayBuffer for sending over UDP. + */ +DNSPacket.prototype.serialize = function() { + var out = new DataWriter(); + var s = ['qd', 'an', 'ns', 'ar']; + + out.short(0).short(this.flags_); + + s.forEach(function(section) { + out.short(this.data_[section].length); + }.bind(this)); + + s.forEach(function(section) { + this.data_[section].forEach(function(rec) { + out.name(rec.name).short(rec.type).short(rec.cl); + + if (section != 'qd') { + // TODO: implement .bytes() + throw new Error('can\'t yet serialize non-QD records'); +// out.long(rec.ttl).bytes(rec.data_); + } + }); + }.bind(this)); + + return out.buffer; +}; + +/** + * DNSRecord is a record inside a DNS packet; e.g. a QUESTION, or an ANSWER, + * AUTHORITY, or ADDITIONAL record. Note that QUESTION records are special, + * and do not have ttl or data. + */ +var DNSRecord = function(name, type, cl, opt_ttl, opt_data) { + this.name = name; + this.type = type; + this.cl = cl; + + this.isQD = (arguments.length == 3); + if (!this.isQD) { + this.ttl = opt_ttl; + this.data_ = opt_data; + } +}; + +DNSRecord.prototype.asName = function() { + return new DataConsumer(this.data_).name(); +}; diff --git a/static/js/mdns-browser/main.js b/static/js/mdns-browser/main.js new file mode 100644 index 0000000..1372413 --- /dev/null +++ b/static/js/mdns-browser/main.js @@ -0,0 +1,197 @@ + +/** + * Construct a new ServiceFinder. This is a single-use object that does a DNS + * multicast search on creation. + * @constructor + * @param {function} callback The callback to be invoked when this object is + * updated, or when an error occurs (passes string). + */ +var ServiceFinder = function(callback) { + this.callback_ = callback; + this.byIP_ = {}; + this.byService_ = {}; + + // Set up receive handlers. + this.onReceiveListener_ = this.onReceive_.bind(this); + chrome.sockets.udp.onReceive.addListener(this.onReceiveListener_); + this.onReceiveErrorListener_ = this.onReceiveError_.bind(this); + chrome.sockets.udp.onReceiveError.addListener(this.onReceiveErrorListener_); + + ServiceFinder.forEachAddress_(function(address, error) { + if (error) { + this.callback_(error); + return true; + } + if (address.indexOf(':') != -1) { + // TODO: ipv6. + console.log('IPv6 address unsupported', address); + return true; + } + console.log('Broadcasting to address', address); + + ServiceFinder.bindToAddress_(address, function(socket) { + if (!socket) { + this.callback_('could not bind UDP socket'); + return true; + } + // Broadcast on it. + this.broadcast_(socket, address); + }.bind(this)); + }.bind(this)); + + // After a short time, if our database is empty, report an error. + setTimeout(function() { + if (!Object.keys(this.byIP_).length) { + this.callback_('no mDNS services found!'); + } + }.bind(this), 10 * 1000); +}; + +/** + * Invokes the callback for every local network address on the system. + * @private + * @param {function} callback to invoke + */ +ServiceFinder.forEachAddress_ = function(callback) { + chrome.system.network.getNetworkInterfaces(function(networkInterfaces) { + if (!networkInterfaces.length) { + callback(null, 'no network available!'); + return true; + } + networkInterfaces.forEach(function(networkInterface) { + callback(networkInterface['address'], null); + }); + }); +}; + +/** + * Creates UDP socket bound to the specified address, passing it to the + * callback. Passes null on failure. + * @private + * @param {string} address to bind to + * @param {function} callback to invoke when done + */ +ServiceFinder.bindToAddress_ = function(address, callback) { + chrome.sockets.udp.create({}, function(createInfo) { + chrome.sockets.udp.bind(createInfo['socketId'], address, 0, + function(result) { + callback((result >= 0) ? createInfo['socketId'] : null); + }); + }); +}; + +/** + * Sorts the passed list of string IPs in-place. + * @private + */ +ServiceFinder.sortIps_ = function(arg) { + arg.sort(ServiceFinder.sortIps_.sort); + return arg; +}; +ServiceFinder.sortIps_.sort = function(l, r) { + // TODO: support v6. + var lp = l.split('.').map(ServiceFinder.sortIps_.toInt_); + var rp = r.split('.').map(ServiceFinder.sortIps_.toInt_); + for (var i = 0; i < Math.min(lp.length, rp.length); ++i) { + if (lp[i] < rp[i]) { + return -1; + } else if (lp[i] > rp[i]) { + return +1; + } + } + return 0; +}; +ServiceFinder.sortIps_.toInt_ = function(i) { return +i }; + +/** + * Returns the services found by this ServiceFinder, optionally filtered by IP. + */ +ServiceFinder.prototype.services = function(opt_ip) { + var k = Object.keys(opt_ip ? this.byIP_[opt_ip] : this.byService_); + k.sort(); + return k; +}; + +/** + * Returns the IPs found by this ServiceFinder, optionally filtered by service. + */ +ServiceFinder.prototype.ips = function(opt_service) { + var k = Object.keys(opt_service ? this.byService_[opt_service] : this.byIP_); + return ServiceFinder.sortIps_(k); +}; + +/** + * Handles an incoming UDP packet. + * @private + */ +ServiceFinder.prototype.onReceive_ = function(info) { + var getDefault_ = function(o, k, def) { + (k in o) || false == (o[k] = def); + return o[k]; + }; + + // Update our local database. + // TODO: Resolve IPs using the dns extension. + var packet = DNSPacket.parse(info.data); + var byIP = getDefault_(this.byIP_, info.remoteAddress, {}); + + packet.each('an', 12, function(rec) { + var ptr = rec.asName(); + var byService = getDefault_(this.byService_, ptr, {}) + byService[info.remoteAddress] = true; + byIP[ptr] = true; + }.bind(this)); + + // Ping! Something new is here. Only update every 25ms. + if (!this.callback_pending_) { + this.callback_pending_ = true; + setTimeout(function() { + this.callback_pending_ = undefined; + this.callback_(); + }.bind(this), 25); + } +}; + +/** + * Handles network error occured while waiting for data. + * @private + */ +ServiceFinder.prototype.onReceiveError_ = function(info) { + this.callback_(info.resultCode); + return true; +} + +/** + * Broadcasts for services on the given socket/address. + * @private + */ +ServiceFinder.prototype.broadcast_ = function(sock, address) { + var packet = new DNSPacket(); + packet.push('qd', new DNSRecord('_services._dns-sd._udp.local', 12, 1)); + + var raw = packet.serialize(); + chrome.sockets.udp.send(sock, raw, '224.0.0.251', 5353, function(sendInfo) { + if (sendInfo.resultCode < 0) + this.callback_('Could not send data to:' + address); + }); +}; + +ServiceFinder.prototype.shutdown = function() { + // Remove event listeners. + chrome.sockets.udp.onReceive.removeListener(this.onReceiveListener_); + chrome.sockets.udp.onReceiveError.removeListener(this.onReceiveErrorListener_); + // Close opened sockets. + chrome.sockets.udp.getSockets(function(sockets) { + sockets.forEach(function(sock) { + chrome.sockets.udp.close(sock.socketId); + }); + }); +} + +var finder = null; +function findNvService(callback) { + finder && finder.shutdown(); + finder = new ServiceFinder(function (opt_error) { + callback(finder, opt_error); + }); +} diff --git a/static/js/utils.js b/static/js/utils.js index cb88393..bf27073 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -29,48 +29,41 @@ function NvHTTP(address, clientUid) { this.clientUid = clientUid; this._baseUrlHttps = 'https://' + address + ':47984'; this._baseUrlHttp = 'http://' + address + ':47989'; + this._appListCache = null; _self = this; }; NvHTTP.prototype = { refreshServerInfo: function () { - return sendMessage('openUrl', [_self._baseUrlHttps+'/serverinfo?'+_self._buildUidStr()]).then(function(ret) { - $xml = _self._parseXML(ret); - $root = $xml.find('root') - - if($root.attr("status_code") == 200) { - _self.paired = $root.find("PairStatus").text().trim() == 1; - _self.currentGame = parseInt($root.find("currentgame").text().trim(), 10); - _self.serverMajorVersion = parseInt($root.find("appversion").text().trim().substring(0, 1), 10); - - // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer - // has the semantics that its name would indicate. To contain the effects of this change as much - // as possible, we'll force the current game to zero if the server isn't in a streaming session. - if ($root.find("state").text().trim().endsWith("_SERVER_AVAILABLE")) { - _self.currentGame = 0; - } + return sendMessage('openUrl', [ _self._baseUrlHttps + '/serverinfo?' + _self._buildUidStr()]).then(function(ret) { + if (!_self._parseServerInfo(ret)) { + return sendMessage('openUrl', [ _self._baseUrlHttp + '/serverinfo?' + _self._buildUidStr()]).then(function(retHttp) { + _self._parseServerInfo(retHttp); + }); } }); }, - - refreshServerInfoUnpaired: function () { - return sendMessage('openUrl', [_self._baseUrlHttp+'/serverinfo?'+_self._buildUidStr()]).then(function(ret) { - $xml = _self._parseXML(ret); - $root = $xml.find('root') - - if($root.attr("status_code") == 200) { - _self.paired = $root.find("PairStatus").text().trim() == 1; - _self.currentGame = parseInt($root.find("currentgame").text().trim(), 10); - _self.serverMajorVersion = parseInt($root.find("appversion").text().trim().substring(0, 1), 10); - - // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer - // has the semantics that its name would indicate. To contain the effects of this change as much - // as possible, we'll force the current game to zero if the server isn't in a streaming session. - if ($root.find("state").text().trim().endsWith("_SERVER_AVAILABLE")) { - _self.currentGame = 0; - } - } - }); + + _parseServerInfo: function(xmlStr) { + $xml = _self._parseXML(xmlStr); + $root = $xml.find('root'); + + if($root.attr("status_code") != 200) { + return false; + } + + _self.paired = $root.find("PairStatus").text().trim() == 1; + _self.currentGame = parseInt($root.find("currentgame").text().trim(), 10); + _self.serverMajorVersion = parseInt($root.find("appversion").text().trim().substring(0, 1), 10); + + // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer + // has the semantics that its name would indicate. To contain the effects of this change as much + // as possible, we'll force the current game to zero if the server isn't in a streaming session. + if ($root.find("state").text().trim().endsWith("_SERVER_AVAILABLE")) { + _self.currentGame = 0; + } + + return true; }, getAppById: function (appId) { @@ -108,32 +101,42 @@ NvHTTP.prototype = { }, getAppList: function () { - console.log('Requested app list'); - return sendMessage('openUrl', [_self._baseUrlHttps+'/applist?'+_self._buildUidStr()]).then(function (ret) { + if (_self._appListCache) { + console.log('Returning app list from cache'); + return new Promise(function (resolve, reject) { + resolve(_self._appListCache); + }); + } + + return sendMessage('openUrl', [_self._baseUrlHttps + '/applist?' + _self._buildUidStr()]).then(function (ret) { $xml = _self._parseXML(ret); var rootElement = $xml.find("root")[0]; var appElements = rootElement.getElementsByTagName("App"); var appList = []; - for(var i = 0, len = appElements.length; i < len; i++) { + for (var i = 0, len = appElements.length; i < len; i++) { appList.push({ title: appElements[i].getElementsByTagName("AppTitle")[0].innerHTML.trim(), id: parseInt(appElements[i].getElementsByTagName("ID")[0].innerHTML.trim(), 10), running: (appElements[i].getElementsByTagName("IsRunning")[0].innerHTML.trim() == 1) }); } + + if (appList) + _self._appListCache = appList; + return appList; }); }, getBoxArt: function (appId) { return sendMessage('openUrl', [ - _self._baseUrlHttps+ - '/appasset?'+_self._buildUidStr()+ + _self._baseUrlHttps + + '/appasset?'+_self._buildUidStr() + '&appid=' + appId + '&AssetType=2&AssetIdx=0' - ]).then(function(ret) { + ]).then(function (ret) { return ret; }); }, @@ -166,11 +169,31 @@ NvHTTP.prototype = { }, quitApp: function () { - return sendMessage('openUrl', [_self._baseUrlHttps+'/cancel?'+_self._buildUidStr()]); + return sendMessage('openUrl', [_self._baseUrlHttps + '/cancel?' + _self._buildUidStr()]).then(function () { + _self.currentGame = 0; + }); + }, + + pair: function(randomNumber) { + return _self.refreshServerInfo().then(function () { + if (_self.paired) + return true; + + if (_self.currentGame != 0) + return false; + + return sendMessage('pair', [_self.serverMajorVersion, _self.address, randomNumber]).then(function (pairStatus) { + return sendMessage('openUrl', [_self._baseUrlHttps + '/pair?uniqueid=' + _self.clientUid + '&devicename=roth&updateState=1&phrase=pairchallenge']).then(function (ret) { + $xml = _self._parseXML(ret); + _self.paired = $xml.find('paired').html() == "1"; + return _self.paired; + }); + }); + }); }, unpair: function () { - return sendMessage('openUrl', [_self._baseUrlHttps+'/unpair?'+_self._buildUidStr()]); + return sendMessage('openUrl', [_self._baseUrlHttps + '/unpair?' + _self._buildUidStr()]); }, _buildUidStr: function () {