diff options
author | Ray Strode <rstrode@redhat.com> | 2013-09-01 20:28:58 -0400 |
---|---|---|
committer | Ray Strode <rstrode@redhat.com> | 2013-09-02 20:31:26 -0400 |
commit | 94b9a615903710c2903b8830957103cee74bed39 (patch) | |
tree | 97c028c1debba01beedf31307196beab95d9426a |
initial commit
This is getting big enough now I should probably keep it in git, so
I don't end up accidentally deleting it.
-rwxr-xr-x | ChromeCast | 3 | ||||
-rw-r--r-- | chromeCast.js | 23 | ||||
-rw-r--r-- | init.js | 5 | ||||
-rw-r--r-- | misc/params.js | 35 | ||||
-rw-r--r-- | protocols/chromeCast.js | 111 | ||||
-rw-r--r-- | protocols/dialDevice.js | 101 | ||||
-rw-r--r-- | protocols/dialManager.js | 64 | ||||
-rw-r--r-- | protocols/webSocket.js | 105 |
8 files changed, 447 insertions, 0 deletions
diff --git a/ChromeCast b/ChromeCast new file mode 100755 index 0000000..ee79396 --- /dev/null +++ b/ChromeCast @@ -0,0 +1,3 @@ +#!/bin/sh +cd $(dirname $0) +gjs -I $PWD init.js diff --git a/chromeCast.js b/chromeCast.js new file mode 100644 index 0000000..1ee6a46 --- /dev/null +++ b/chromeCast.js @@ -0,0 +1,23 @@ +const Lang = imports.lang; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Gssdp = imports.gi.GSSDP; +const Gupnp = imports.gi.GUPnP; +const Soup = imports.gi.Soup; + +const ChromeCast = imports.protocols.chromeCast; + +function init() { + let loop = GLib.MainLoop.new(null, false); + + let chromeCast = new ChromeCast.ChromeCast(); + + chromeCast.connect('device-found', Lang.bind(this, function(chromeCast, device) { + print('device found', device.description); + if (device.state == 'stopped') { + chromeCast.castToDevice(device); + } + })); + + loop.run(); +} @@ -0,0 +1,5 @@ +const ChromeCast = imports.chromeCast; + +print('init'); +ChromeCast.init(); + diff --git a/misc/params.js b/misc/params.js new file mode 100644 index 0000000..2b9dc0c --- /dev/null +++ b/misc/params.js @@ -0,0 +1,35 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +// parse: +// @params: caller-provided parameter object, or %null +// @defaults: function-provided defaults object +// @allowExtras: whether or not to allow properties not in @default +// +// Examines @params and fills in default values from @defaults for +// any properties in @defaults that don't appear in @params. If +// @allowExtras is not %true, it will throw an error if @params +// contains any properties that aren't in @defaults. +// +// If @params is %null, this returns the values from @defaults. +// +// Return value: a new object, containing the merged parameters from +// @params and @defaults +function parse(params, defaults, allowExtras) { + let ret = {}, prop; + + if (!params) + params = {}; + + for (prop in params) { + if (!(prop in defaults) && !allowExtras) + throw new Error('Unrecognized parameter "' + prop + '"'); + ret[prop] = params[prop]; + } + + for (prop in defaults) { + if (!(prop in params)) + ret[prop] = defaults[prop]; + } + + return ret; +}
\ No newline at end of file diff --git a/protocols/chromeCast.js b/protocols/chromeCast.js new file mode 100644 index 0000000..b0abaef --- /dev/null +++ b/protocols/chromeCast.js @@ -0,0 +1,111 @@ +const Lang = imports.lang; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Signals = imports.signals; +const Soup = imports.gi.Soup; + +const DialManager = imports.protocols.dialManager; +const WebSocket = imports.protocols.webSocket; + +const CLIENT_ORIGIN = 'chrome-extension://boadgeojelhgndaghljhdicfkmllpafd'; +const SERVICE_VERSION = 'release-3df2f7159e87f36d38115f377f5ca88335dd3649'; +const REFRESH_INTERVAL = 1000; // milliseconds + +const ChromeCast = new Lang.Class({ + Name: 'ChromeCast', + + _init: function() { + this._dialManager = new DialManager.DialManager({ appName: 'ChromeCast' }); + this._senderId = this._generateSenderId(); + + this._dialManager.connect('device-found', + Lang.bind(this, function(manager, dialDevice) { + if (dialDevice.name == 'ChromeCast') { + print('device found'); + this.emit('device-found', dialDevice); + } + })); + + this._dialManager.discover(); + }, + + _generateSenderId: function() { + let nonce = ""; + for (let i = 0; i < 4; i++) + nonce += String.fromCharCode(GLib.random_int_range(0, 255)); + + return GLib.base64_encode(nonce, nonce.length); + }, + + _openCastSession: function(session, uri) { + print('opening cast session'); + let message = new Soup.Message({ method: 'POST', + uri: uri }); + let formData = JSON.stringify({ channel: 0, + senderId: { appName: 'ChromeCast', + senderId: this._senderId }}); + print('formdata', formData); + message.set_request('application/json', + Soup.MemoryUse.COPY, + formData, + formData.length); + message.request_headers.replace('Origin', CLIENT_ORIGIN); + session.queue_message(message, + Lang.bind(this, function() { + print('response: ', message.status_code, message.reason_phrase); + let buffer = message.response_body.flatten(); + let castSession = JSON.parse(buffer.get_as_bytes().get_data()); + print(JSON.stringify(castSession)); + + if (castSession.URL) { + let webSocket = new WebSocket.WebSocket({ origin: CLIENT_ORIGIN, + pingInterval: castSession.pingInterval }); + + webSocket.open(castSession.URL, Lang.bind(this, function() { + print('websocket opened'); + })); + } + })); + }, + + _castWhenReady: function(session, dialDevice, connectionUri) { + print('waiting to cast until app is ready'); + dialDevice.refresh(Lang.bind(this, function(uri, app) { + if (!dialDevice.serviceURL) + GLib.timeout_add(GLib.PRIORITY_DEFAULT, + REFRESH_INTERVAL, + Lang.bind(this, function() { + this._castWhenReady(session, dialDevice, connectionUri); + })); + else + this._openCastSession(session, new Soup.URI(dialDevice.serviceURL)); + })); + }, + + castToDevice: function(dialDevice) { + print('starting ChromeCast app'); + let session = new Soup.Session(); + let message = new Soup.Message({ method: 'POST', + uri: dialDevice.appResourceUri }); + print('about to cast', dialDevice.appResourceUri.to_string(false)); + let formData = Soup.form_encode_hash({ v: SERVICE_VERSION, + id: 'local:0', + idle: 'windowclose' }); + message.set_request(Soup.FORM_MIME_TYPE_URLENCODED, + Soup.MemoryUse.COPY, + formData, + formData.length); + session.queue_message(message, + Lang.bind(this, function() { + let headers = message.response_headers; + let connectionUri = headers.get('Location'); + + print('response: ', message.status_code, message.reason_phrase); + if (connectionUri) { + print('Connection URI', connectionUri); + this._castWhenReady(session, dialDevice, connectionUri); + } + })); + }, +}); +Signals.addSignalMethods(ChromeCast.prototype); diff --git a/protocols/dialDevice.js b/protocols/dialDevice.js new file mode 100644 index 0000000..1c1570d --- /dev/null +++ b/protocols/dialDevice.js @@ -0,0 +1,101 @@ +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Gssdp = imports.gi.GSSDP; +const Gupnp = imports.gi.GUPnP; +const Signals = imports.signals; +const Soup = imports.gi.Soup; + +const Lang = imports.lang; +const Params = imports.misc.params; + +const DialDevice = new Lang.Class({ + Name: 'DialDevice', + + _init: function(params) { + params = Params.parse(params, + { httpSession: null, + restServiceUri: null, + appName: null }); + + if (params.httpSession) + this._httpSession = params.httpSession; + else + this._httpSession = new Soup.Session(); + + this._restServiceUri = params.restServiceUri; + this.appName = params.appName; + this.appResourceUri = this._restServiceUri.new_with_base(this.appName); + }, + + _parseChromeCastNode: function(chromeCastNode) { + // xmlns="urn:chrome.google.com:cast" + let nodes = chromeCastNode.*; + for (let i = 0; i < nodes.length(); i++) { + let node = nodes[i]; + let tag = node.localName(); + let value = node.text().toString(); + + if (chromeCastNode.localName() == 'servicedata') { + if (tag == 'connectionSvcURL') { + this.serviceURL = value; + } else if (tag == 'protocols') { + let protocolNodes = node.*; + this.protocols = []; + for (let j = 0; j < protocolNodes.length(); j++) { + let protocolNode = protocolNodes[j]; + this.protocols.push(protocolNode.text().toString()); + } + } + } else if (chromeCastNode.localName() == 'activity-status') { + if (tag == 'description') + this.description = value; + else if (tag == 'image') + this.image = value; + } + } + }, + + _parseResourceDescription: function(description) { + // Chop off the E4X incompatible <?xml?> header + // xmlns="urn:dial-multiscreen-org:schemas:dial + description = String(description).replace(/<\?xml[^>]*\?>/, ''); + let service = XML(description); + + let properties = {}; + let nodes = service.*; + for (let i = 0; i < nodes.length(); i++) { + let node = nodes[i]; + let tag = node.localName(); + let value = node.text().toString(); + + if (tag == 'name') + this.name = value; + else if (tag == 'options') + this.allowStop = node.@allowStop; + else if (tag == 'state') + this.state = value; + else if (tag == 'link' && node.@rel == 'run') + this.instanceUri = node.@href; + else if (tag == 'servicedata') + this._parseChromeCastNode(node); + else if (tag == 'activity-status') + this._parseChromeCastNode(node); + } + }, + + refresh: function(onRefreshed) { + print('get service properties'); + let message = new Soup.Message({ method: 'GET', + uri: this.appResourceUri }); + this._httpSession.queue_message(message, + Lang.bind(this, function() { + let buffer = message.response_body.flatten(); + let description = buffer.get_as_bytes().get_data(); + print('\n', description); + this._parseResourceDescription(description); + + onRefreshed(this); + })); + } +}); +Signals.addSignalMethods(DialDevice.prototype); diff --git a/protocols/dialManager.js b/protocols/dialManager.js new file mode 100644 index 0000000..31b0015 --- /dev/null +++ b/protocols/dialManager.js @@ -0,0 +1,64 @@ +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Gssdp = imports.gi.GSSDP; +const Gupnp = imports.gi.GUPnP; +const Signals = imports.signals; +const Soup = imports.gi.Soup; + +const Lang = imports.lang; +const Params = imports.misc.params; +const DialDevice = imports.protocols.dialDevice; +const WebSocket = imports.protocols.webSocket; + +const DIAL_MULTISCREEN_TARGET_URN = 'urn:dial-multiscreen-org:service:dial:1'; + +const DialManager = new Lang.Class({ + Name: 'DialManager', + + _init: function(params) { + params = Params.parse(params, + { httpSession: null, + appName: null }); + + if (params.httpSession) + this._httpSession = params.httpSession; + else + this._httpSession = new Soup.Session(); + + this._appName = params.appName; + + // FIXME: use NetworkManager or something to get a list of all interfaces, + // and then have one context per interface + this._upnpContext = new Gupnp.Context({ interface: 'wlp2s0' }); + this._upnpContext.init(null); + + this._upnpControlPoint = new Gupnp.ControlPoint({ client: this._upnpContext, + target: DIAL_MULTISCREEN_TARGET_URN }); + this._upnpControlPoint.connect('service-proxy-available', + Lang.bind(this, this._onFoundDevice)); + }, + + _onFoundDevice: function(controlPoint, proxy) { + print('found device', proxy.location); + let message = new Soup.Message({ method: 'HEAD', + uri: new Soup.URI(proxy.location) }); + this._httpSession.queue_message(message, + Lang.bind(this, function() { + let restServiceUri = message.response_headers.get('Application-URL'); + if (restServiceUri) { + print('REST uri', restServiceUri); + let dialDevice = new DialDevice.DialDevice({ restServiceUri: new Soup.URI(restServiceUri), + appName: this._appName }); + dialDevice.refresh(Lang.bind(this, function() { + this.emit('device-found', dialDevice); + })); + } + })); + }, + + discover: function() { + print('beginning discovery'); + this._upnpControlPoint.set_active(true); + } +}); +Signals.addSignalMethods(DialManager.prototype); diff --git a/protocols/webSocket.js b/protocols/webSocket.js new file mode 100644 index 0000000..a9e7d14 --- /dev/null +++ b/protocols/webSocket.js @@ -0,0 +1,105 @@ +// Protocol: http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-13 +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Lang = imports.lang; +const Signals = imports.signals; +const Soup = imports.gi.Soup; + +const Params = imports.misc.params; + +const WEB_SOCKET_VERSION = '13'; +const WEB_SOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +const WebSocketFrame = new Lang.Class({ + Name: 'WebSocketFrame', + _init: function(params) { + params = Params.parse(params, + { pingInterval: null }); + }, +}); +Signals.addSignalMethods(WebSocketFrame.prototype); + +const WebSocket = new Lang.Class({ + Name: 'WebSocket', + + _init: function(params) { + params = Params.parse(params, + { session: null, + origin: null, + pingInterval: null }); + if (params.session) + this._session = params.session; + else + this._session = new Soup.Session(); + + this._origin = params.origin; + this._pingInterval = params.pingInterval; + }, + + _rewriteUri: function() { + // WebSockets have a ws:// scheme, but libsoup only does http right now + // so rewrite it. + let port = this._uri.get_port(); + this._uri.set_scheme('http'); + this._uri.set_port(port); + }, + + _generateKey: function() { + let nonce = ""; + for (let i = 0; i < 16; i++) + nonce += String.fromCharCode(GLib.random_int_range(0, 255)); + + return GLib.base64_encode(nonce, nonce.length); + }, + + sendClose: function() { + }, + + sendPing: function() { + }, + + sendPong: function() { + }, + + sendText: function(text) { + }, + + sendData: function(data) { + }, + + open: function(uri, onOpened) { + this._uri = new Soup.URI(uri); + this._rewriteUri(); + + this._message = new Soup.Message({ method: 'GET', + uri: this._uri }); + + this._session.connect('request-started', + Lang.bind(this, function(server, message, socket) { + this._message.request_headers.replace('Upgrade', 'websocket'); + this._message.request_headers.replace('Connection', 'Upgrade'); + + if (this._origin) + this._message.request_headers.replace('Origin', this._origin); + + this._key = this._generateKey(); + + this._message.request_headers.replace('Sec-WebSocket-Key', this._key); + this._message.request_headers.replace('Sec-WebSocket-Version', WEB_SOCKET_VERSION); + this._socketFd = socket.get_fd(); + })); + + this._message.connect('got-informational', + Lang.bind(this, function() { + // At this point the handshake is complete, and the stream is now a + // websocket stream, not an http stream + this._session.pause_message(this._message); + this._socket = new Gio.Socket({ fd: this._socketFd }); + + onOpened(); + })); + + this._session.queue_message(this._message, null); + } +}); +Signals.addSignalMethods(WebSocket.prototype); |