summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRay Strode <rstrode@redhat.com>2013-09-01 20:28:58 -0400
committerRay Strode <rstrode@redhat.com>2013-09-02 20:31:26 -0400
commit94b9a615903710c2903b8830957103cee74bed39 (patch)
tree97c028c1debba01beedf31307196beab95d9426a
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-xChromeCast3
-rw-r--r--chromeCast.js23
-rw-r--r--init.js5
-rw-r--r--misc/params.js35
-rw-r--r--protocols/chromeCast.js111
-rw-r--r--protocols/dialDevice.js101
-rw-r--r--protocols/dialManager.js64
-rw-r--r--protocols/webSocket.js105
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();
+}
diff --git a/init.js b/init.js
new file mode 100644
index 0000000..c61ef05
--- /dev/null
+++ b/init.js
@@ -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);