From e13118cc969692d608b66726968822f2c06902f2 Mon Sep 17 00:00:00 2001 From: Mark Hammond Date: Fri, 3 Feb 2012 15:50:20 +1100 Subject: [PATCH] demos ugly but working --- README.md | 52 +++ clients/simple.html | 49 ++ handlers/README.md | 9 + handlers/sample_handler.html | 25 ++ handlers/sample_handler.png | Bin 0 -> 7316 bytes intents-repo.html | 7 + intents-repo.js | 47 ++ intents.js | 838 +++++++++++++++++++++++++++++++++++ jschannel.js | 614 +++++++++++++++++++++++++ picker.css | 78 ++++ picker.html | 203 +++++++++ server.py | 46 ++ 12 files changed, 1968 insertions(+) create mode 100644 README.md create mode 100644 clients/simple.html create mode 100644 handlers/README.md create mode 100644 handlers/sample_handler.html create mode 100644 handlers/sample_handler.png create mode 100644 intents-repo.html create mode 100644 intents-repo.js create mode 100644 intents.js create mode 100644 jschannel.js create mode 100644 picker.css create mode 100644 picker.html create mode 100644 server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..96c98ce --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +Introduction +------------ + +This is an implementation of a WebIntents-like API, as sketched out by Ian Hickson in http://lists.w3.org/Archives/Public/public-webapps/2011JulSep/1509.html + +This is implemented as a pure-JS shim - pages need only load `intents.js` and they can function as an invoker of intent or as a handler of intents. A rough outline of how it hangs together is: + +* intents.js arranges to create a hidden iframe back to localhost:8888 and uses a jschannel to communicate for registration and unregistration. localStorage on this host is the "repository". +* When client code calls `invokeIntent()`, the picker is displayed an a jschannel created between the client code and the picker. +* The picker loads the intent handlers in an iframe and yet another jschannel exists between the picker and the handler. +Thus, the picker acts as a kind of relay - when the client communicates with the handler it goes over its jschannel to the picker, which then relays it over the jschannel to the handler. + +As Firefox doesn't implement MessagePorts yet, a simple port-like javascript object is used. + +Running the demo +---------------- + +* Start `server.py` in the root of this tree. This will start a http server on port 8888. +* Open http://localhost:8888/handlers/sample_handler.html in your browser. Not much will happen but it should call registerIntentHandler to register that page as a handler for a 'share' intent. Close the page. + +We also use the Firefox Share addon as an OAuth helper for the google etc plugins. This takes a few steps. + +* Grab the `experiment/hixtents` branch from the git repo at https://github.com/mhammond/fx-share-addon +* Grab the `develop` branches from the repos at https://github.com/mozilla/oauthorizer/ and https://github.com/mozilla/activities +* After activating the addon-sdk, run the command: +`cfx run --pkgdir=path-to-fx-share-addon --package-path=path-to-oauthorizer package-path=path-to-activities` +* Start `server.py' in the root of the fx-share-addon tree. This will start a http server on port 8889. +* Open http://localhost:8889/data/apps/google/google.html - this will register that page as a share handler. Close the page. +* Open clients/task.html in the browser - you can open this either as a file:/// URL or via the server (ie, http://localhost:8888/clients/simple.html. +* Click on the link to invoke a share intent. + +You should now see the picker with the 2 handlers displayed in tabs. The google service should be fully functional - ie, you can login and send an email. + +Notable differences from Hixie's sketch +--------------------------------------- + +Instead of: + + var port = navigator.handleIntent(intent, filter); + port.postMessage(data); + port.onmessage = function (event) { handle(event.data) }; + +we use a callback approach: + + navigator.handleIntent(intent, filter, function(port) { + port.postMessage(data); + port.onmessage = function (event) { handle(event.data) }; + }); + +This is done as it make take some time for the UI to select an intent handler and we want to avoid blocking the main thread. + +Similarly, `registerIntentHandler`, `isIntentHandlerRegistered` and `unregisterIntentHandler` take a callback, although this was done for pragmatic reasons - the shim uses `postMessage` to a server for the respository implementation. diff --git a/clients/simple.html b/clients/simple.html new file mode 100644 index 0000000..f673b5c --- /dev/null +++ b/clients/simple.html @@ -0,0 +1,49 @@ + + + + + + + Click to invoke 'test' intent... + Click to invoke 'share' intent... + + + diff --git a/handlers/README.md b/handlers/README.md new file mode 100644 index 0000000..aabc779 --- /dev/null +++ b/handlers/README.md @@ -0,0 +1,9 @@ +This directory contains some demo "intent handlers". While they can be served by the server as a convenience, they don't rely on being on the same origin as the server. One way to test this out is to load them as file:/// URLs - but Browsers take a dim view of file:/// content hosted in iframes. To work around this, you can modify your profile's user.js and add the following lines: + +``` +user_pref("capability.policy.policynames", "localfilelinks"); +user_pref("capability.policy.localfilelinks.sites", "http://localhost:8888"); +user_pref("capability.policy.localfilelinks.checkloaduri.enabled", "allAccess"); +``` + +You will then be able to load them from a file:/// URL (to re-register it) and the picker will display them. diff --git a/handlers/sample_handler.html b/handlers/sample_handler.html new file mode 100644 index 0000000..7773c08 --- /dev/null +++ b/handlers/sample_handler.html @@ -0,0 +1,25 @@ + + + A test handler + + + + + + + Hey - I'm an intent handler and + I'm waiting for intent data + + \ No newline at end of file diff --git a/handlers/sample_handler.png b/handlers/sample_handler.png new file mode 100644 index 0000000000000000000000000000000000000000..840e2c6e9c99a7a91a2cc68083aa35c6b0e98230 GIT binary patch literal 7316 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 + + + + + + diff --git a/intents-repo.js b/intents-repo.js new file mode 100644 index 0000000..f60159e --- /dev/null +++ b/intents-repo.js @@ -0,0 +1,47 @@ + +(function() { + + function _getIntentRegistrations() { + if (localStorage.intentHandlers) { + return JSON.parse(localStorage.intentHandlers); + } + return {}; + } + + function _setIntentRegistrations(ob) { + localStorage.intentHandlers = JSON.stringify(ob); + } + + var chan = Channel.build({window: window.parent, origin: "*", scope: "intents_channel"}); + chan.bind("registerIntentHandler", function(trans, data) { + var intentsOb = _getIntentRegistrations(); + if (!intentsOb[data.intent]) { + intentsOb[data.intent] = {}; + } + if (!intentsOb[data.intent][data.filter]) { + intentsOb[data.intent][data.filter] = {}; + } + intentsOb[data.intent][data.filter][data.url] = data; + _setIntentRegistrations(intentsOb); + }); + + chan.bind("isIntentHandlerRegistered", function(trans, data) { + var intentsOb = _getIntentRegistrations(); + try { + return !!intentsOb[data.intent][data.filter][data.url]; + } catch(ex) { + return false; + } + }); + + chan.bind("unregisterIntentandler", function(trans, data) { + var intentsOb = _getIntentRegistrations(); + try { + delete localStorage.intentHandlers[data.intent][data.filter][data.url]; + } catch (ex) { + return; // nothing to do. + } + _setIntentRegistrations(intentsOb); + }); + +})(); diff --git a/intents.js b/intents.js new file mode 100644 index 0000000..027fd0e --- /dev/null +++ b/intents.js @@ -0,0 +1,838 @@ + +console = { + log: function() { + var args = Array.prototype.slice.call(arguments); + dump(args.join(" ") + "\n"); + } +}; + +// This uses potentially 3 different channels: +// * One to intents-repo.html for "repository" (ie localStorage) related work. +// * If this module is loaded by a client window, there is a channel between +// that client window and the picker. +// * If this module is loaded by an intent handler, there is a channel between +// that handler and the picker. +// Obviously only one of the last 2 will actually be active for a given +// instance of this script. +(function() { + var intentsRepoOrigin = "http://localhost:8888"; + var intentsRepoPath = "/intents-repo.html"; + var intentsPickerPath = "/picker.html"; + var pickerUrlOrigin = "*"; // XXX - fix this once there is a real origin!! + + // from webintents.js + var getFavIcon = function() { + var links = document.getElementsByTagName("link"); + var link; + for(var i = 0; link = links[i]; i++) { + if((link.rel == "icon" || link.rel == "shortcut") && !!link.href ) { + var url = link.href; + if(url.substring(0, 7) == "file://") // hack for a demo! + return url; + if(url.substring(0, 7) != "http://" && + url.substring(0, 8) != "https://") { + if(url.substring(0,1) == "/") { + // absolute path + return window.location.protocol + "//" + window.location.host + "/" + url; + } + else { + // relative path + var path = document.location.href; + path = path.substring(0, path.lastIndexOf('/') + 1); + url = path + url; + dump("RESULT " + url); + return url; + } + } + else { + return url; + } + } + } + + return window.location.protocol + "//" + window.location.host + "/favicon.ico"; + }; + + function defaultErrorHandler(err, msg) { + dump("channel error: " + err + "/" + msg + "\n"); + } + + // Attempt to create a jschannel between the picker window and us. + function setupPickerHandlerChannel() { + var pickerHandlerChannel = Channel.build({ + window: window.top, + origin: pickerUrlOrigin, + scope: "intents_handler_channel", + onReady: function() { + pickerHandlerReady = true; + } + }); + // and for the delivery of intents to the handler + var currentIntentPort = null; + pickerHandlerChannel.bind("intentInitialize", function(trans, data) { + currentIntentPort = null; + }); + pickerHandlerChannel.bind("intentRequest", function(trans, data) { + if (!window.onintent) { + // strange, but nothing we can do. + return; + } +// console.log("intents.js intentRequest handler for", window.location.href, "-", JSON.stringify(data), "with existing port", currentIntentPort); + if (!currentIntentPort) { + // simulate a port object - we don't seem able to use real ports yet, + // see https://bugzilla.mozilla.org/show_bug.cgi?id=677638 - then call + // window.onintent. + currentIntentPort = { + postMessage: function(response) { +// console.log("intent port sending", response, "to the picker"); + pickerHandlerChannel.call({method: "intentResponse", + params: response, + success: function() {}, + error: defaultErrorHandler + }); + } + // the onintent handler may attach an onmessage... + }; + var simevt = { + ports: [currentIntentPort], + data: data + }; + window.onintent(simevt); + } else { + // We've already created the port and called onintent - so just + // deliver this directly to the port. + // (and just let things die a noisy death if there is no onmessage + // for the port - it is a good clue to the dev that the handler + // isn't expecting further data...) + currentIntentPort.onmessage(data); + }; + }); + }; + + window.addEventListener("load", function() { + var pickerHandlerReady = false; + + if (window !== window.parent) { + // We are in an iframe, so this module might have been loaded by the + // intent handler. + setupPickerHandlerChannel(); + } + + // hacky helper for file:// stuff... + function getOriginForPM(origin) { + if (!origin || origin == "null") return '*'; + return origin; + } + + var _repoChannel; + function _getRepoChannel() { + if (!_repoChannel) { + // Create hidden iframe dom element + var doc = window.document; + var _iframe = doc.createElement("iframe"); + _iframe.style.display = "none"; + // Append iframe to the dom and load up our repo inside + doc.body.appendChild(_iframe); + _iframe.src = intentsRepoOrigin + intentsRepoPath; + + _repoChannel = Channel.build({ + window: _iframe.contentWindow, + origin: intentsRepoOrigin, + scope: "intents_channel" + }); + } + return _repoChannel; + }; + + navigator.registerIntentHandler = function(intent, filter, url, kind) { + var title = document.title || window.location.host; + var icon = getFavIcon(); + var params = {intent: intent, filter: filter, url: url, kind: kind, + title: title, icon: icon}; + _getRepoChannel().call({method: "registerIntentHandler", + params: params, + success: function() {}, + error: defaultErrorHandler + }); + }; + + navigator.isIntentHandlerRegistered = function(intent, filter, url, callback) { + var params = {intent: intent, filter: filter, url: url}; + _getRepoChannel().call({method: "isIntentHandlerRegistered", + params: params, + success: function(result) {callback(result)}, + error: function(err) {callback(false);} + }); + }; + + navigator.unregisterIntentandler = function(intent, filter, url) { + var params = {intent: intent, filter: filter, url: url}; + _getRepoChannel().call({method: "unregisterIntentandler", + params: params, + success: function() {}, + error: defaultErrorHandler + }); + }; + + navigator.handleIntent = function(intent, contentType, callback) { + // There was a misguided attempt at reusing an existing picker window, but + // for now just create a new one each time. + // XXX - needs more thought as an existing one will still have a channel + // open... + var features = 'height=400,width=500'; + var pickerUrl = intentsRepoOrigin + intentsPickerPath; + var pickerWindow = window.open(pickerUrl, "_blank", features); + // create a jschannel between us and the picker. + var pickerClientChannel = Channel.build({ + window: pickerWindow, + origin: '*', + scope: "intents_client_channel", + onReady: function() { + console.log("intents.js channel to picker is ready"); + var data = {intent: intent, contentType: contentType}; + pickerClientChannel.call({method: "intentHandle", + params: data, + success: function() {}, + error: defaultErrorHandler + }); + } + }); + pickerClientChannel.bind("intentBegin", function(trans, data) { + // The picker has wired-up the intent with a specific service, so now + // we can simulate a port object and callback into the client. + var port = { + postMessage: function(data) { + // when the client calls postMessage it just ends up as an + // 'intentRequest' call on our channel. + pickerClientChannel.call({method: 'intentRequest', + params: data, + success: function() {}, + error: defaultErrorHandler + }); + } + }; + callback(port); + }); + + }; // end of navigator.handleIntent. + }, false); + +// inline copy of jschannel.js +/** + * js_channel is a very lightweight abstraction on top of + * postMessage which defines message formats and semantics + * to support interactions more rich than just message passing + * js_channel supports: + * + query/response - traditional rpc + * + query/update/response - incremental async return of results + * to a query + * + notifications - fire and forget + * + error handling + * + * js_channel is based heavily on json-rpc, but is focused at the + * problem of inter-iframe RPC. + * + * Message types: + * There are 5 types of messages that can flow over this channel, + * and you may determine what type of message an object is by + * examining its parameters: + * 1. Requests + * + integer id + * + string method + * + (optional) any params + * 2. Callback Invocations (or just "Callbacks") + * + integer id + * + string callback + * + (optional) params + * 3. Error Responses (or just "Errors) + * + integer id + * + string error + * + (optional) string message + * 4. Responses + * + integer id + * + (optional) any result + * 5. Notifications + * + string method + * + (optional) any params + */ + +;var Channel = (function() { + "use strict"; + + // current transaction id, start out at a random *odd* number between 1 and a million + // There is one current transaction counter id per page, and it's shared between + // channel instances. That means of all messages posted from a single javascript + // evaluation context, we'll never have two with the same id. + var s_curTranId = Math.floor(Math.random()*1000001); + + // no two bound channels in the same javascript evaluation context may have the same origin, scope, and window. + // futher if two bound channels have the same window and scope, they may not have *overlapping* origins + // (either one or both support '*'). This restriction allows a single onMessage handler to efficiently + // route messages based on origin and scope. The s_boundChans maps origins to scopes, to message + // handlers. Request and Notification messages are routed using this table. + // Finally, channels are inserted into this table when built, and removed when destroyed. + var s_boundChans = { }; + + // add a channel to s_boundChans, throwing if a dup exists + function s_addBoundChan(win, origin, scope, handler) { + function hasWin(arr) { + for (var i = 0; i < arr.length; i++) if (arr[i].win === win) return true; + return false; + } + + // does she exist? + var exists = false; + + + if (origin === '*') { + // we must check all other origins, sadly. + for (var k in s_boundChans) { + if (!s_boundChans.hasOwnProperty(k)) continue; + if (k === '*') continue; + if (typeof s_boundChans[k][scope] === 'object') { + exists = hasWin(s_boundChans[k][scope]); + if (exists) break; + } + } + } else { + // we must check only '*' + if ((s_boundChans['*'] && s_boundChans['*'][scope])) { + exists = hasWin(s_boundChans['*'][scope]); + } + if (!exists && s_boundChans[origin] && s_boundChans[origin][scope]) + { + exists = hasWin(s_boundChans[origin][scope]); + } + } + if (exists) throw "A channel is already bound to the same window which overlaps with origin '"+ origin +"' and has scope '"+scope+"'"; + + if (typeof s_boundChans[origin] != 'object') s_boundChans[origin] = { }; + if (typeof s_boundChans[origin][scope] != 'object') s_boundChans[origin][scope] = [ ]; + s_boundChans[origin][scope].push({win: win, handler: handler}); + } + + function s_removeBoundChan(win, origin, scope) { + var arr = s_boundChans[origin][scope]; + for (var i = 0; i < arr.length; i++) { + if (arr[i].win === win) { + arr.splice(i,1); + } + } + if (s_boundChans[origin][scope].length === 0) { + delete s_boundChans[origin][scope] + } + } + + function s_isArray(obj) { + if (Array.isArray) return Array.isArray(obj); + else { + return (obj.constructor.toString().indexOf("Array") != -1); + } + } + + // No two outstanding outbound messages may have the same id, period. Given that, a single table + // mapping "transaction ids" to message handlers, allows efficient routing of Callback, Error, and + // Response messages. Entries are added to this table when requests are sent, and removed when + // responses are received. + var s_transIds = { }; + + // class singleton onMessage handler + // this function is registered once and all incoming messages route through here. This + // arrangement allows certain efficiencies, message data is only parsed once and dispatch + // is more efficient, especially for large numbers of simultaneous channels. + var s_onMessage = function(e) { + try { + var m = JSON.parse(e.data); + if (typeof m !== 'object' || m === null) throw "malformed"; + } catch(e) { + // just ignore any posted messages that do not consist of valid JSON + return; + } + + var w = e.source; + var o = e.origin; + var s, i, meth; + + if (typeof m.method === 'string') { + var ar = m.method.split('::'); + if (ar.length == 2) { + s = ar[0]; + meth = ar[1]; + } else { + meth = m.method; + } + } + + if (typeof m.id !== 'undefined') i = m.id; + + // w is message source window + // o is message origin + // m is parsed message + // s is message scope + // i is message id (or undefined) + // meth is unscoped method name + // ^^ based on these factors we can route the message + + // if it has a method it's either a notification or a request, + // route using s_boundChans + if (typeof meth === 'string') { + var delivered = false; + if (s_boundChans[o] && s_boundChans[o][s]) { + for (var i = 0; i < s_boundChans[o][s].length; i++) { + if (s_boundChans[o][s][i].win === w) { + s_boundChans[o][s][i].handler(o, meth, m); + delivered = true; + break; + } + } + } + + if (!delivered && s_boundChans['*'] && s_boundChans['*'][s]) { + for (var i = 0; i < s_boundChans['*'][s].length; i++) { + if (s_boundChans['*'][s][i].win === w) { + s_boundChans['*'][s][i].handler(o, meth, m); + break; + } + } + } + } + // otherwise it must have an id (or be poorly formed + else if (typeof i != 'undefined') { + if (s_transIds[i]) s_transIds[i](o, meth, m); + } + }; + + // Setup postMessage event listeners + if (window.addEventListener) window.addEventListener('message', s_onMessage, false); + else if(window.attachEvent) window.attachEvent('onmessage', s_onMessage); + + /* a messaging channel is constructed from a window and an origin. + * the channel will assert that all messages received over the + * channel match the origin + * + * Arguments to Channel.build(cfg): + * + * cfg.window - the remote window with which we'll communicate + * cfg.origin - the expected origin of the remote window, may be '*' + * which matches any origin + * cfg.scope - the 'scope' of messages. a scope string that is + * prepended to message names. local and remote endpoints + * of a single channel must agree upon scope. Scope may + * not contain double colons ('::'). + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.postMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these immediately + * before messages are posted. + * cfg.gotMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these arguments + * immediately after they pass scope and origin checks, but before + * they are processed. + * cfg.onReady - A function that will be invoked when a channel becomes "ready", + * this occurs once both sides of the channel have been + * instantiated and an application level handshake is exchanged. + * the onReady function will be passed a single argument which is + * the channel object that was returned from build(). + */ + return { + build: function(cfg) { + var debug = function(m) { + if (cfg.debugOutput && window.console && window.console.log) { + // try to stringify, if it doesn't work we'll let javascript's built in toString do its magic + try { if (typeof m !== 'string') m = JSON.stringify(m); } catch(e) { } + console.log("["+chanId+"] " + m); + } + } + + /* browser capabilities check */ + if (!window.postMessage) throw("jschannel cannot run this browser, no postMessage"); + if (!window.JSON || !window.JSON.stringify || ! window.JSON.parse) { + throw("jschannel cannot run this browser, no JSON parsing/serialization"); + } + + /* basic argument validation */ + if (typeof cfg != 'object') throw("Channel build invoked without a proper object argument"); + + if (!cfg.window || !cfg.window.postMessage) throw("Channel.build() called without a valid window argument"); + + /* we'd have to do a little more work to be able to run multiple channels that intercommunicate the same + * window... Not sure if we care to support that */ + if (window === cfg.window) throw("target window is same as present window -- not allowed"); + + // let's require that the client specify an origin. if we just assume '*' we'll be + // propagating unsafe practices. that would be lame. + var validOrigin = false; + if (typeof cfg.origin === 'string') { + var oMatch; + if (cfg.origin === "*") validOrigin = true; + // allow valid domains under http and https. Also, trim paths off otherwise valid origins. + else if (null !== (oMatch = cfg.origin.match(/^https?:\/\/(?:[-a-zA-Z0-9\.])+(?::\d+)?/))) { + cfg.origin = oMatch[0].toLowerCase(); + validOrigin = true; + } + } + + if (!validOrigin) throw ("Channel.build() called with an invalid origin"); + + if (typeof cfg.scope !== 'undefined') { + if (typeof cfg.scope !== 'string') throw 'scope, when specified, must be a string'; + if (cfg.scope.split('::').length > 1) throw "scope may not contain double colons: '::'" + } + + /* private variables */ + // generate a random and psuedo unique id for this channel + var chanId = (function () { + var text = ""; + var alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + for(var i=0; i < 5; i++) text += alpha.charAt(Math.floor(Math.random() * alpha.length)); + return text; + })(); + + // registrations: mapping method names to call objects + var regTbl = { }; + // current oustanding sent requests + var outTbl = { }; + // current oustanding received requests + var inTbl = { }; + // are we ready yet? when false we will block outbound messages. + var ready = false; + var pendingQueue = [ ]; + + var createTransaction = function(id,origin,callbacks) { + var shouldDelayReturn = false; + var completed = false; + + return { + origin: origin, + invoke: function(cbName, v) { + // verify in table + if (!inTbl[id]) throw "attempting to invoke a callback of a nonexistent transaction: " + id; + // verify that the callback name is valid + var valid = false; + for (var i = 0; i < callbacks.length; i++) if (cbName === callbacks[i]) { valid = true; break; } + if (!valid) throw "request supports no such callback '" + cbName + "'"; + + // send callback invocation + postMessage({ id: id, callback: cbName, params: v}); + }, + error: function(error, message) { + completed = true; + // verify in table + if (!inTbl[id]) throw "error called for nonexistent message: " + id; + + // remove transaction from table + delete inTbl[id]; + + // send error + postMessage({ id: id, error: error, message: message }); + }, + complete: function(v) { + completed = true; + // verify in table + if (!inTbl[id]) throw "complete called for nonexistent message: " + id; + // remove transaction from table + delete inTbl[id]; + // send complete + postMessage({ id: id, result: v }); + }, + delayReturn: function(delay) { + if (typeof delay === 'boolean') { + shouldDelayReturn = (delay === true); + } + return shouldDelayReturn; + }, + completed: function() { + return completed; + } + }; + } + + var setTransactionTimeout = function(transId, timeout, method) { + return window.setTimeout(function() { + if (outTbl[transId]) { + // XXX: what if client code raises an exception here? + var msg = "timeout (" + timeout + "ms) exceeded on method '" + method + "'"; + (1,outTbl[transId].error)("timeout_error", msg); + delete outTbl[transId]; + delete s_transIds[transId]; + } + }, timeout); + } + + var onMessage = function(origin, method, m) { + // if an observer was specified at allocation time, invoke it + if (typeof cfg.gotMessageObserver === 'function') { + // pass observer a clone of the object so that our + // manipulations are not visible (i.e. method unscoping). + // This is not particularly efficient, but then we expect + // that message observers are primarily for debugging anyway. + try { + cfg.gotMessageObserver(origin, m); + } catch (e) { + debug("gotMessageObserver() raised an exception: " + e.toString()); + } + } + + // now, what type of message is this? + if (m.id && method) { + // a request! do we have a registered handler for this request? + if (regTbl[method]) { + var trans = createTransaction(m.id, origin, m.callbacks ? m.callbacks : [ ]); + inTbl[m.id] = { }; + try { + // callback handling. we'll magically create functions inside the parameter list for each + // callback + if (m.callbacks && s_isArray(m.callbacks) && m.callbacks.length > 0) { + for (var i = 0; i < m.callbacks.length; i++) { + var path = m.callbacks[i]; + var obj = m.params; + var pathItems = path.split('/'); + for (var j = 0; j < pathItems.length - 1; j++) { + var cp = pathItems[j]; + if (typeof obj[cp] !== 'object') obj[cp] = { }; + obj = obj[cp]; + } + obj[pathItems[pathItems.length - 1]] = (function() { + var cbName = path; + return function(params) { + return trans.invoke(cbName, params); + } + })(); + } + } + var resp = regTbl[method](trans, m.params); + if (!trans.delayReturn() && !trans.completed()) trans.complete(resp); + } catch(e) { + // automagic handling of exceptions: + dump("ERROR " + typeof e + "/" + e.toString() + "/" + method + "\n"); + var error = "runtime_error"; + var message = null; + // * if it's a string then it gets an error code of 'runtime_error' and string is the message + if (typeof e === 'string') { + message = e; + } else if (typeof e === 'object') { + // either an array or an object + // * if it's an array of length two, then array[0] is the code, array[1] is the error message + if (e && s_isArray(e) && e.length == 2) { + error = e[0]; + message = e[1]; + } + // * if it's an object then we'll look form error and message parameters + else if (typeof e.error === 'string') { + error = e.error; + if (!e.message) message = ""; + else if (typeof e.message === 'string') message = e.message; + else e = e.message; // let the stringify/toString message give us a reasonable verbose error string + } + } + + // message is *still* null, let's try harder + if (message === null) { + try { + message = JSON.stringify(e); + /* On MSIE8, this can result in 'out of memory', which + * leaves message undefined. */ + if (typeof(message) == 'undefined') + message = e.toString(); + } catch (e2) { + message = e.toString(); + } + } + + trans.error(error,message); + } + } + } else if (m.id && m.callback) { + if (!outTbl[m.id] ||!outTbl[m.id].callbacks || !outTbl[m.id].callbacks[m.callback]) + { + debug("ignoring invalid callback, id:"+m.id+ " (" + m.callback +")"); + } else { + // XXX: what if client code raises an exception here? + outTbl[m.id].callbacks[m.callback](m.params); + } + } else if (m.id) { + if (!outTbl[m.id]) { + debug("ignoring invalid response: " + m.id); + } else { + // XXX: what if client code raises an exception here? + if (m.error) { + (1,outTbl[m.id].error)(m.error, m.message); + } else { + if (m.result !== undefined) (1,outTbl[m.id].success)(m.result); + else (1,outTbl[m.id].success)(); + } + delete outTbl[m.id]; + delete s_transIds[m.id]; + } + } else if (method) { + // tis a notification. + if (regTbl[method]) { + // yep, there's a handler for that. + // transaction is null for notifications. + regTbl[method](null, m.params); + // if the client throws, we'll just let it bubble out + // what can we do? Also, here we'll ignore return values + } + } + } + + // now register our bound channel for msg routing + s_addBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''), onMessage); + + // scope method names based on cfg.scope specified when the Channel was instantiated + var scopeMethod = function(m) { + if (typeof cfg.scope === 'string' && cfg.scope.length) m = [cfg.scope, m].join("::"); + return m; + } + + // a small wrapper around postmessage whose primary function is to handle the + // case that clients start sending messages before the other end is "ready" + var postMessage = function(msg, force) { + if (!msg) throw "postMessage called with null message"; + + // delay posting if we're not ready yet. + var verb = (ready ? "post " : "queue "); + debug(verb + " message: " + JSON.stringify(msg)); + if (!force && !ready) { + pendingQueue.push(msg); + } else { + if (typeof cfg.postMessageObserver === 'function') { + try { + cfg.postMessageObserver(cfg.origin, msg); + } catch (e) { + debug("postMessageObserver() raised an exception: " + e.toString()); + } + } + + cfg.window.postMessage(JSON.stringify(msg), cfg.origin); + } + } + + var onReady = function(trans, type) { + debug('ready msg received'); + if (ready) throw "received ready message while in ready state. help!"; + + if (type === 'ping') { + chanId += '-R'; + } else { + chanId += '-L'; + } + + obj.unbind('__ready'); // now this handler isn't needed any more. + ready = true; + debug('ready msg accepted.'); + + if (type === 'ping') { + obj.notify({ method: '__ready', params: 'pong' }); + } + + // flush queue + while (pendingQueue.length) { + postMessage(pendingQueue.pop()); + } + + // invoke onReady observer if provided + if (typeof cfg.onReady === 'function') cfg.onReady(obj); + }; + + var obj = { + // tries to unbind a bound message handler. returns false if not possible + unbind: function (method) { + if (regTbl[method]) { + if (!(delete regTbl[method])) throw ("can't delete method: " + method); + return true; + } + return false; + }, + bind: function (method, cb) { + if (!method || typeof method !== 'string') throw "'method' argument to bind must be string"; + if (!cb || typeof cb !== 'function') throw "callback missing from bind params"; + + if (regTbl[method]) throw "method '"+method+"' is already bound!"; + regTbl[method] = cb; + return this; + }, + call: function(m) { + if (!m) throw 'missing arguments to call function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to call must be string"; + if (!m.success || typeof m.success !== 'function') throw "'success' callback missing from call"; + + // now it's time to support the 'callback' feature of jschannel. We'll traverse the argument + // object and pick out all of the functions that were passed as arguments. + var callbacks = { }; + var callbackNames = [ ]; + + var pruneFunctions = function (path, obj) { + if (typeof obj === 'object') { + for (var k in obj) { + if (!obj.hasOwnProperty(k)) continue; + var np = path + (path.length ? '/' : '') + k; + if (typeof obj[k] === 'function') { + callbacks[np] = obj[k]; + callbackNames.push(np); + delete obj[k]; + } else if (typeof obj[k] === 'object') { + pruneFunctions(np, obj[k]); + } + } + } + }; + pruneFunctions("", m.params); + + // build a 'request' message and send it + var msg = { id: s_curTranId, method: scopeMethod(m.method), params: m.params }; + if (callbackNames.length) msg.callbacks = callbackNames; + + if (m.timeout) + // XXX: This function returns a timeout ID, but we don't do anything with it. + // We might want to keep track of it so we can cancel it using clearTimeout() + // when the transaction completes. + setTransactionTimeout(s_curTranId, m.timeout, scopeMethod(m.method)); + + // insert into the transaction table + outTbl[s_curTranId] = { callbacks: callbacks, error: m.error, success: m.success }; + s_transIds[s_curTranId] = onMessage; + + // increment current id + s_curTranId++; + + postMessage(msg); + }, + notify: function(m) { + if (!m) throw 'missing arguments to notify function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to notify must be string"; + + // no need to go into any transaction table + postMessage({ method: scopeMethod(m.method), params: m.params }); + }, + destroy: function () { + s_removeBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : '')); + if (window.removeEventListener) window.removeEventListener('message', onMessage, false); + else if(window.detachEvent) window.detachEvent('onmessage', onMessage); + ready = false; + regTbl = { }; + inTbl = { }; + outTbl = { }; + cfg.origin = null; + pendingQueue = [ ]; + debug("channel destroyed"); + chanId = ""; + } + }; + + obj.bind('__ready', onReady); + setTimeout(function() { + postMessage({ method: scopeMethod('__ready'), params: "ping" }, true); + }, 0); + + return obj; + } + }; +})(); +// end of inline copy of jschannel.js + +})(); diff --git a/jschannel.js b/jschannel.js new file mode 100644 index 0000000..86a9f59 --- /dev/null +++ b/jschannel.js @@ -0,0 +1,614 @@ +/** + * js_channel is a very lightweight abstraction on top of + * postMessage which defines message formats and semantics + * to support interactions more rich than just message passing + * js_channel supports: + * + query/response - traditional rpc + * + query/update/response - incremental async return of results + * to a query + * + notifications - fire and forget + * + error handling + * + * js_channel is based heavily on json-rpc, but is focused at the + * problem of inter-iframe RPC. + * + * Message types: + * There are 5 types of messages that can flow over this channel, + * and you may determine what type of message an object is by + * examining its parameters: + * 1. Requests + * + integer id + * + string method + * + (optional) any params + * 2. Callback Invocations (or just "Callbacks") + * + integer id + * + string callback + * + (optional) params + * 3. Error Responses (or just "Errors) + * + integer id + * + string error + * + (optional) string message + * 4. Responses + * + integer id + * + (optional) any result + * 5. Notifications + * + string method + * + (optional) any params + */ + +;var Channel = (function() { + "use strict"; + + // current transaction id, start out at a random *odd* number between 1 and a million + // There is one current transaction counter id per page, and it's shared between + // channel instances. That means of all messages posted from a single javascript + // evaluation context, we'll never have two with the same id. + var s_curTranId = Math.floor(Math.random()*1000001); + + // no two bound channels in the same javascript evaluation context may have the same origin, scope, and window. + // futher if two bound channels have the same window and scope, they may not have *overlapping* origins + // (either one or both support '*'). This restriction allows a single onMessage handler to efficiently + // route messages based on origin and scope. The s_boundChans maps origins to scopes, to message + // handlers. Request and Notification messages are routed using this table. + // Finally, channels are inserted into this table when built, and removed when destroyed. + var s_boundChans = { }; + + // add a channel to s_boundChans, throwing if a dup exists + function s_addBoundChan(win, origin, scope, handler) { + function hasWin(arr) { + for (var i = 0; i < arr.length; i++) if (arr[i].win === win) return true; + return false; + } + + // does she exist? + var exists = false; + + + if (origin === '*') { + // we must check all other origins, sadly. + for (var k in s_boundChans) { + if (!s_boundChans.hasOwnProperty(k)) continue; + if (k === '*') continue; + if (typeof s_boundChans[k][scope] === 'object') { + exists = hasWin(s_boundChans[k][scope]); + if (exists) break; + } + } + } else { + // we must check only '*' + if ((s_boundChans['*'] && s_boundChans['*'][scope])) { + exists = hasWin(s_boundChans['*'][scope]); + } + if (!exists && s_boundChans[origin] && s_boundChans[origin][scope]) + { + exists = hasWin(s_boundChans[origin][scope]); + } + } + if (exists) throw "A channel is already bound to the same window which overlaps with origin '"+ origin +"' and has scope '"+scope+"'"; + + if (typeof s_boundChans[origin] != 'object') s_boundChans[origin] = { }; + if (typeof s_boundChans[origin][scope] != 'object') s_boundChans[origin][scope] = [ ]; + s_boundChans[origin][scope].push({win: win, handler: handler}); + } + + function s_removeBoundChan(win, origin, scope) { + var arr = s_boundChans[origin][scope]; + for (var i = 0; i < arr.length; i++) { + if (arr[i].win === win) { + arr.splice(i,1); + } + } + if (s_boundChans[origin][scope].length === 0) { + delete s_boundChans[origin][scope] + } + } + + function s_isArray(obj) { + if (Array.isArray) return Array.isArray(obj); + else { + return (obj.constructor.toString().indexOf("Array") != -1); + } + } + + // No two outstanding outbound messages may have the same id, period. Given that, a single table + // mapping "transaction ids" to message handlers, allows efficient routing of Callback, Error, and + // Response messages. Entries are added to this table when requests are sent, and removed when + // responses are received. + var s_transIds = { }; + + // class singleton onMessage handler + // this function is registered once and all incoming messages route through here. This + // arrangement allows certain efficiencies, message data is only parsed once and dispatch + // is more efficient, especially for large numbers of simultaneous channels. + var s_onMessage = function(e) { + try { + var m = JSON.parse(e.data); + if (typeof m !== 'object' || m === null) throw "malformed"; + } catch(e) { + // just ignore any posted messages that do not consist of valid JSON + return; + } + + var w = e.source; + var o = e.origin; + var s, i, meth; + + if (typeof m.method === 'string') { + var ar = m.method.split('::'); + if (ar.length == 2) { + s = ar[0]; + meth = ar[1]; + } else { + meth = m.method; + } + } + + if (typeof m.id !== 'undefined') i = m.id; + + // w is message source window + // o is message origin + // m is parsed message + // s is message scope + // i is message id (or undefined) + // meth is unscoped method name + // ^^ based on these factors we can route the message + + // if it has a method it's either a notification or a request, + // route using s_boundChans + if (typeof meth === 'string') { + var delivered = false; + if (s_boundChans[o] && s_boundChans[o][s]) { + for (var i = 0; i < s_boundChans[o][s].length; i++) { + if (s_boundChans[o][s][i].win === w) { + s_boundChans[o][s][i].handler(o, meth, m); + delivered = true; + break; + } + } + } + + if (!delivered && s_boundChans['*'] && s_boundChans['*'][s]) { + for (var i = 0; i < s_boundChans['*'][s].length; i++) { + if (s_boundChans['*'][s][i].win === w) { + s_boundChans['*'][s][i].handler(o, meth, m); + break; + } + } + } + } + // otherwise it must have an id (or be poorly formed + else if (typeof i != 'undefined') { + if (s_transIds[i]) s_transIds[i](o, meth, m); + } + }; + + // Setup postMessage event listeners + if (window.addEventListener) window.addEventListener('message', s_onMessage, false); + else if(window.attachEvent) window.attachEvent('onmessage', s_onMessage); + + /* a messaging channel is constructed from a window and an origin. + * the channel will assert that all messages received over the + * channel match the origin + * + * Arguments to Channel.build(cfg): + * + * cfg.window - the remote window with which we'll communicate + * cfg.origin - the expected origin of the remote window, may be '*' + * which matches any origin + * cfg.scope - the 'scope' of messages. a scope string that is + * prepended to message names. local and remote endpoints + * of a single channel must agree upon scope. Scope may + * not contain double colons ('::'). + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.debugOutput - A boolean value. If true and window.console.log is + * a function, then debug strings will be emitted to that + * function. + * cfg.postMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these immediately + * before messages are posted. + * cfg.gotMessageObserver - A function that will be passed two arguments, + * an origin and a message. It will be passed these arguments + * immediately after they pass scope and origin checks, but before + * they are processed. + * cfg.onReady - A function that will be invoked when a channel becomes "ready", + * this occurs once both sides of the channel have been + * instantiated and an application level handshake is exchanged. + * the onReady function will be passed a single argument which is + * the channel object that was returned from build(). + */ + return { + build: function(cfg) { + var debug = function(m) { + if (cfg.debugOutput && window.console && window.console.log) { + // try to stringify, if it doesn't work we'll let javascript's built in toString do its magic + try { if (typeof m !== 'string') m = JSON.stringify(m); } catch(e) { } + console.log("["+chanId+"] " + m); + } + } + + /* browser capabilities check */ + if (!window.postMessage) throw("jschannel cannot run this browser, no postMessage"); + if (!window.JSON || !window.JSON.stringify || ! window.JSON.parse) { + throw("jschannel cannot run this browser, no JSON parsing/serialization"); + } + + /* basic argument validation */ + if (typeof cfg != 'object') throw("Channel build invoked without a proper object argument"); + + if (!cfg.window || !cfg.window.postMessage) throw("Channel.build() called without a valid window argument"); + + /* we'd have to do a little more work to be able to run multiple channels that intercommunicate the same + * window... Not sure if we care to support that */ + if (window === cfg.window) throw("target window is same as present window -- not allowed"); + + // let's require that the client specify an origin. if we just assume '*' we'll be + // propagating unsafe practices. that would be lame. + var validOrigin = false; + if (typeof cfg.origin === 'string') { + var oMatch; + if (cfg.origin === "*") validOrigin = true; + // allow valid domains under http and https. Also, trim paths off otherwise valid origins. + else if (null !== (oMatch = cfg.origin.match(/^https?:\/\/(?:[-a-zA-Z0-9\.])+(?::\d+)?/))) { + cfg.origin = oMatch[0].toLowerCase(); + validOrigin = true; + } + } + + if (!validOrigin) throw ("Channel.build() called with an invalid origin"); + + if (typeof cfg.scope !== 'undefined') { + if (typeof cfg.scope !== 'string') throw 'scope, when specified, must be a string'; + if (cfg.scope.split('::').length > 1) throw "scope may not contain double colons: '::'" + } + + /* private variables */ + // generate a random and psuedo unique id for this channel + var chanId = (function () { + var text = ""; + var alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + for(var i=0; i < 5; i++) text += alpha.charAt(Math.floor(Math.random() * alpha.length)); + return text; + })(); + + // registrations: mapping method names to call objects + var regTbl = { }; + // current oustanding sent requests + var outTbl = { }; + // current oustanding received requests + var inTbl = { }; + // are we ready yet? when false we will block outbound messages. + var ready = false; + var pendingQueue = [ ]; + + var createTransaction = function(id,origin,callbacks) { + var shouldDelayReturn = false; + var completed = false; + + return { + origin: origin, + invoke: function(cbName, v) { + // verify in table + if (!inTbl[id]) throw "attempting to invoke a callback of a nonexistent transaction: " + id; + // verify that the callback name is valid + var valid = false; + for (var i = 0; i < callbacks.length; i++) if (cbName === callbacks[i]) { valid = true; break; } + if (!valid) throw "request supports no such callback '" + cbName + "'"; + + // send callback invocation + postMessage({ id: id, callback: cbName, params: v}); + }, + error: function(error, message) { + completed = true; + // verify in table + if (!inTbl[id]) throw "error called for nonexistent message: " + id; + + // remove transaction from table + delete inTbl[id]; + + // send error + postMessage({ id: id, error: error, message: message }); + }, + complete: function(v) { + completed = true; + // verify in table + if (!inTbl[id]) throw "complete called for nonexistent message: " + id; + // remove transaction from table + delete inTbl[id]; + // send complete + postMessage({ id: id, result: v }); + }, + delayReturn: function(delay) { + if (typeof delay === 'boolean') { + shouldDelayReturn = (delay === true); + } + return shouldDelayReturn; + }, + completed: function() { + return completed; + } + }; + } + + var setTransactionTimeout = function(transId, timeout, method) { + return window.setTimeout(function() { + if (outTbl[transId]) { + // XXX: what if client code raises an exception here? + var msg = "timeout (" + timeout + "ms) exceeded on method '" + method + "'"; + (1,outTbl[transId].error)("timeout_error", msg); + delete outTbl[transId]; + delete s_transIds[transId]; + } + }, timeout); + } + + var onMessage = function(origin, method, m) { + // if an observer was specified at allocation time, invoke it + if (typeof cfg.gotMessageObserver === 'function') { + // pass observer a clone of the object so that our + // manipulations are not visible (i.e. method unscoping). + // This is not particularly efficient, but then we expect + // that message observers are primarily for debugging anyway. + try { + cfg.gotMessageObserver(origin, m); + } catch (e) { + debug("gotMessageObserver() raised an exception: " + e.toString()); + } + } + + // now, what type of message is this? + if (m.id && method) { + // a request! do we have a registered handler for this request? + if (regTbl[method]) { + var trans = createTransaction(m.id, origin, m.callbacks ? m.callbacks : [ ]); + inTbl[m.id] = { }; + try { + // callback handling. we'll magically create functions inside the parameter list for each + // callback + if (m.callbacks && s_isArray(m.callbacks) && m.callbacks.length > 0) { + for (var i = 0; i < m.callbacks.length; i++) { + var path = m.callbacks[i]; + var obj = m.params; + var pathItems = path.split('/'); + for (var j = 0; j < pathItems.length - 1; j++) { + var cp = pathItems[j]; + if (typeof obj[cp] !== 'object') obj[cp] = { }; + obj = obj[cp]; + } + obj[pathItems[pathItems.length - 1]] = (function() { + var cbName = path; + return function(params) { + return trans.invoke(cbName, params); + } + })(); + } + } + var resp = regTbl[method](trans, m.params); + if (!trans.delayReturn() && !trans.completed()) trans.complete(resp); + } catch(e) { + // automagic handling of exceptions: + var error = "runtime_error"; + var message = null; + // * if it's a string then it gets an error code of 'runtime_error' and string is the message + if (typeof e === 'string') { + message = e; + } else if (typeof e === 'object') { + // either an array or an object + // * if it's an array of length two, then array[0] is the code, array[1] is the error message + if (e && s_isArray(e) && e.length == 2) { + error = e[0]; + message = e[1]; + } + // * if it's an object then we'll look form error and message parameters + else if (typeof e.error === 'string') { + error = e.error; + if (!e.message) message = ""; + else if (typeof e.message === 'string') message = e.message; + else e = e.message; // let the stringify/toString message give us a reasonable verbose error string + } + } + + // message is *still* null, let's try harder + if (message === null) { + try { + message = JSON.stringify(e); + /* On MSIE8, this can result in 'out of memory', which + * leaves message undefined. */ + if (typeof(message) == 'undefined') + message = e.toString(); + } catch (e2) { + message = e.toString(); + } + } + + trans.error(error,message); + } + } + } else if (m.id && m.callback) { + if (!outTbl[m.id] ||!outTbl[m.id].callbacks || !outTbl[m.id].callbacks[m.callback]) + { + debug("ignoring invalid callback, id:"+m.id+ " (" + m.callback +")"); + } else { + // XXX: what if client code raises an exception here? + outTbl[m.id].callbacks[m.callback](m.params); + } + } else if (m.id) { + if (!outTbl[m.id]) { + debug("ignoring invalid response: " + m.id); + } else { + // XXX: what if client code raises an exception here? + if (m.error) { + (1,outTbl[m.id].error)(m.error, m.message); + } else { + if (m.result !== undefined) (1,outTbl[m.id].success)(m.result); + else (1,outTbl[m.id].success)(); + } + delete outTbl[m.id]; + delete s_transIds[m.id]; + } + } else if (method) { + // tis a notification. + if (regTbl[method]) { + // yep, there's a handler for that. + // transaction is null for notifications. + regTbl[method](null, m.params); + // if the client throws, we'll just let it bubble out + // what can we do? Also, here we'll ignore return values + } + } + } + + // now register our bound channel for msg routing + s_addBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : ''), onMessage); + + // scope method names based on cfg.scope specified when the Channel was instantiated + var scopeMethod = function(m) { + if (typeof cfg.scope === 'string' && cfg.scope.length) m = [cfg.scope, m].join("::"); + return m; + } + + // a small wrapper around postmessage whose primary function is to handle the + // case that clients start sending messages before the other end is "ready" + var postMessage = function(msg, force) { + if (!msg) throw "postMessage called with null message"; + + // delay posting if we're not ready yet. + var verb = (ready ? "post " : "queue "); + debug(verb + " message: " + JSON.stringify(msg)); + if (!force && !ready) { + pendingQueue.push(msg); + } else { + if (typeof cfg.postMessageObserver === 'function') { + try { + cfg.postMessageObserver(cfg.origin, msg); + } catch (e) { + debug("postMessageObserver() raised an exception: " + e.toString()); + } + } + + cfg.window.postMessage(JSON.stringify(msg), cfg.origin); + } + } + + var onReady = function(trans, type) { + debug('ready msg received'); + if (ready) throw "received ready message while in ready state. help!"; + + if (type === 'ping') { + chanId += '-R'; + } else { + chanId += '-L'; + } + + obj.unbind('__ready'); // now this handler isn't needed any more. + ready = true; + debug('ready msg accepted.'); + + if (type === 'ping') { + obj.notify({ method: '__ready', params: 'pong' }); + } + + // flush queue + while (pendingQueue.length) { + postMessage(pendingQueue.pop()); + } + + // invoke onReady observer if provided + if (typeof cfg.onReady === 'function') cfg.onReady(obj); + }; + + var obj = { + // tries to unbind a bound message handler. returns false if not possible + unbind: function (method) { + if (regTbl[method]) { + if (!(delete regTbl[method])) throw ("can't delete method: " + method); + return true; + } + return false; + }, + bind: function (method, cb) { + if (!method || typeof method !== 'string') throw "'method' argument to bind must be string"; + if (!cb || typeof cb !== 'function') throw "callback missing from bind params"; + + if (regTbl[method]) throw "method '"+method+"' is already bound!"; + regTbl[method] = cb; + return this; + }, + call: function(m) { + if (!m) throw 'missing arguments to call function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to call must be string"; + if (!m.success || typeof m.success !== 'function') throw "'success' callback missing from call"; + + // now it's time to support the 'callback' feature of jschannel. We'll traverse the argument + // object and pick out all of the functions that were passed as arguments. + var callbacks = { }; + var callbackNames = [ ]; + + var pruneFunctions = function (path, obj) { + if (typeof obj === 'object') { + for (var k in obj) { + if (!obj.hasOwnProperty(k)) continue; + var np = path + (path.length ? '/' : '') + k; + if (typeof obj[k] === 'function') { + callbacks[np] = obj[k]; + callbackNames.push(np); + delete obj[k]; + } else if (typeof obj[k] === 'object') { + pruneFunctions(np, obj[k]); + } + } + } + }; + pruneFunctions("", m.params); + + // build a 'request' message and send it + var msg = { id: s_curTranId, method: scopeMethod(m.method), params: m.params }; + if (callbackNames.length) msg.callbacks = callbackNames; + + if (m.timeout) + // XXX: This function returns a timeout ID, but we don't do anything with it. + // We might want to keep track of it so we can cancel it using clearTimeout() + // when the transaction completes. + setTransactionTimeout(s_curTranId, m.timeout, scopeMethod(m.method)); + + // insert into the transaction table + outTbl[s_curTranId] = { callbacks: callbacks, error: m.error, success: m.success }; + s_transIds[s_curTranId] = onMessage; + + // increment current id + s_curTranId++; + + postMessage(msg); + }, + notify: function(m) { + if (!m) throw 'missing arguments to notify function'; + if (!m.method || typeof m.method !== 'string') throw "'method' argument to notify must be string"; + + // no need to go into any transaction table + postMessage({ method: scopeMethod(m.method), params: m.params }); + }, + destroy: function () { + s_removeBoundChan(cfg.window, cfg.origin, ((typeof cfg.scope === 'string') ? cfg.scope : '')); + if (window.removeEventListener) window.removeEventListener('message', onMessage, false); + else if(window.detachEvent) window.detachEvent('onmessage', onMessage); + ready = false; + regTbl = { }; + inTbl = { }; + outTbl = { }; + cfg.origin = null; + pendingQueue = [ ]; + debug("channel destroyed"); + chanId = ""; + } + }; + + obj.bind('__ready', onReady); + setTimeout(function() { + postMessage({ method: scopeMethod('__ready'), params: "ping" }, true); + }, 0); + + return obj; + } + }; +})(); diff --git a/picker.css b/picker.css new file mode 100644 index 0000000..a65e1a0 --- /dev/null +++ b/picker.css @@ -0,0 +1,78 @@ +.serviceIcon { + width:24px; + height:24px; + vertical-align:middle; +} +.serviceTitle { + max-width:76px; + vertical-align:middle; + text-align:center; + display:block; + overflow-x:hidden; +} +.serviceTab { + text-align:center; + max-width:76px; + overflow-x:hidden; + color:black; + font:0.7em 'Lucida Grande',Tahoma,Arial,sans-serif; +} + +#services { + border-radius:0; + -moz-user-select:none; +} + + +ul.tabs { + margin: 0; + padding: 0; + float: left; + list-style: none; + height: 62px; /*--Set height of tabs--*/ + border-bottom: 1px solid #999; + border-left: 1px solid #999; + width: 100%; +} +ul.tabs li { + float: left; + margin: 0; + padding: 0; + height: 61px; /*--Subtract 1px from the height of the unordered list--*/ + line-height: 31px; /*--Vertically aligns the text within the tab--*/ + border: 1px solid #999; + border-left: none; + margin-bottom: -1px; /*--Pull the list item down 1px--*/ + overflow: hidden; + position: relative; + background: #e0e0e0; +} +ul.tabs li a { + text-decoration: none; + color: #000; + display: block; + font-size: 1.2em; + padding: 0 20px; + border: 1px solid #fff; /*--Gives the bevel look with a 1px white border inside the list item--*/ + outline: none; +} +ul.tabs li a:hover { + background: #ccc; +} +html ul.tabs li.active, html ul.tabs li.active a:hover { /*--Makes sure that the active tab does not listen to the hover properties--*/ + background: #fff; + border-bottom: 1px solid #fff; /*--Makes the active tab look like it's connected with its content--*/ +} + +.tab_container { + border: 1px solid #999; + border-top: none; + overflow: hidden; + clear: both; + float: left; width: 100%; + background: #fff; +} +.tab_content { + padding: 20px; + font-size: 1.2em; +} diff --git a/picker.html b/picker.html new file mode 100644 index 0000000..ed0c87c --- /dev/null +++ b/picker.html @@ -0,0 +1,203 @@ + + + + + + + + + + + + +

+ I'm an intent picker window - I've been created with an intent + ??? + and a content-type of ??? +

+ +
+
+
+
+
+
+ + diff --git a/server.py b/server.py new file mode 100644 index 0000000..af1905f --- /dev/null +++ b/server.py @@ -0,0 +1,46 @@ +import sys +import os +import posixpath +import urllib +from SimpleHTTPServer import SimpleHTTPRequestHandler +from BaseHTTPServer import HTTPServer + +class Handler(SimpleHTTPRequestHandler): + def translate_path(self, path): + """Translate a /-separated PATH to the local filename syntax. + + Components that mean special things to the local file system + (e.g. drive or directory names) are ignored. (XXX They should + probably be diagnosed.) + + """ + # abandon query parameters + path = path.split('?',1)[0] + path = path.split('#',1)[0] + path = posixpath.normpath(urllib.unquote(path)) + words = path.split('/') + words = filter(None, words) + path = os.path.dirname(__file__) + for word in words: + drive, word = os.path.splitdrive(word) + head, word = os.path.split(word) + if word in (os.curdir, os.pardir): continue + path = os.path.join(path, word) + return path + +def main(): + if sys.argv[1:]: + port = int(sys.argv[1]) + else: + port = 8888 + server_address = ('', port) + + #Handler.protocol_version = "HTTP/1.1" + httpd = HTTPServer(server_address, Handler) + + sa = httpd.socket.getsockname() + print "Serving HTTP on", sa[0], "port", sa[1], "..." + httpd.serve_forever() + +if __name__=='__main__': + main()