1 /*
  2  * RapidContext <http://www.rapidcontext.com/>
  3  * Copyright (c) 2007-2013 Per Cederberg. All rights reserved.
  4  *
  5  * This program is free software: you can redistribute it and/or
  6  * modify it under the terms of the BSD license.
  7  *
  8  * This program is distributed in the hope that it will be useful,
  9  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 10  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 11  *
 12  * See the RapidContext LICENSE for more details.
 13  */
 14
 15 /**
 16  * @name RapidContext
 17  * @namespace The base RapidContext namespace.
 18  */
 19 if (typeof(RapidContext) == "undefined") {
 20     RapidContext = {};
 21 }
 22
 23 /**
 24  * @name RapidContext.App
 25  * @namespace Provides functions for application bootstrap and server
 26  *     communication.
 27  */
 28 if (typeof(RapidContext.App) == "undefined") {
 29     RapidContext.App = {};
 30 }
 31
 32 /**
 33  * Initializes the platform, API:s and RapidContext UI. If an app
 34  * identifier is provided, the default platform UI will not be
 35  * created. Instead the app will be launched with the root document
 36  * as its UI container.
 37  *
 38  * @param {String/Object} [app] the app id or class name to start
 39  *
 40  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
 41  *         callback when the initialization has completed
 42  */
 43 RapidContext.App.init = function (app) {
 44     // Setup libraries
 45     RapidContext.Log.context("RapidContext.App.init()");
 46     RapidContext.Log.info("Initializing RapidContext");
 47     RapidContext.Util.registerFunctionNames(RapidContext, "RapidContext");
 48
 49     // Setup UI
 50     RapidContext.Util.registerSizeConstraints(document.body, "100%-20", "100%-20");
 51     var resizer = MochiKit.Base.partial(RapidContext.Util.resizeElements, document.body);
 52     MochiKit.Signal.connect(window, "onresize", resizer);
 53     RapidContext.Util.resizeElements(document.body);
 54     var overlay = new RapidContext.Widget.Overlay({ message: "Loading..." });
 55     MochiKit.DOM.replaceChildNodes(document.body, overlay);
 56
 57     // Load platform data
 58     var list = [ RapidContext.App.callProc("System.Status"),
 59                  RapidContext.App.callProc("System.Session.Current"),
 60                  RapidContext.App.callProc("System.App.List") ];
 61     var d = MochiKit.Async.gatherResults(list);
 62
 63     // Launch app
 64     d.addBoth(function () {
 65         RapidContext.Log.context(null);
 66     });
 67     d.addCallback(function () {
 68         try {
 69             return RapidContext.App.startApp(app || "start");
 70         } catch (e) {
 71             RapidContext.UI.showError(e);
 72             return RapidContext.App.startApp("start");
 73         }
 74     });
 75     d.addErrback(RapidContext.UI.showError);
 76     return d;
 77 };
 78
 79 /**
 80  * Returns an object with status information about the platform and
 81  * currently loaded environment. The object returned is a copy of
 82  * the internal data structure and can be modified without affecting
 83  * the real data.
 84  *
 85  * @return {Object} the status data object
 86  */
 87 RapidContext.App.status = function () {
 88     // TODO: use deep clone
 89     return MochiKit.Base.clone(RapidContext.App._Cache.status);
 90 };
 91
 92 /**
 93  * Returns an object with information about the user. The object
 94  * returned is a copy of the internal data structure and can be
 95  * modified without affecting the real data.
 96  *
 97  * @return {Object} the user data object
 98  */
 99 RapidContext.App.user = function () {
100     // TODO: use deep clone
101     return MochiKit.Base.clone(RapidContext.App._Cache.user);
102 };
103
104 /**
105  * Returns an array with app launchers. The array returned is an
106  * internal data structure and should not be modified directly.
107  *
108  * @return {Array} the loaded app launchers (read-only)
109  */
110 RapidContext.App.apps = function () {
111     // TODO: use deep clone, but copy instance array content
112     return RapidContext.App._Cache.apps;
113 };
114
115 /**
116  * Returns an array with running app instances.
117  *
118  * @return {Array} the array of app instances
119  */
120 RapidContext.App._instances = function () {
121     var res = [];
122     var apps = RapidContext.App.apps();
123     for (var i = 0; i < apps.length; i++) {
124         res = res.concat(apps[i].instances || []);
125     }
126     return res;
127 };
128
129 /**
130  * Finds the app launcher from an app instance, class name or
131  * launcher. In the last case, the matching cached launcher will be
132  * returned.
133  *
134  * @param {String/Object} app the app id, instance, class name or
135  *        launcher
136  *
137  * @return {Object} the read-only app launcher, or
138  *         `null` if not found
139  */
140 RapidContext.App.findApp = function (app) {
141     if (app == null) {
142         return null;
143     }
144     var apps = RapidContext.App.apps();
145     for (var i = 0; i < apps.length; i++) {
146         var l = apps[i];
147         if (l.className == null) {
148             RapidContext.Log.error("Launcher does not have 'className' property", l);
149         } else if (l.id == app || l.className == app || l.id == app.id) {
150             return l;
151         }
152     }
153     return null;
154 };
155
156 RapidContext.App._cbAssign = function (obj, key) {
157     return function (data) {
158         obj[key] = data;
159         return data;
160     }
161 }
162
163 /**
164  * Creates and starts an app instance. Normally apps are started in the default
165  * app tab container or in a provided widget DOM node. By specifying a newly
166  * created window object as the parent container, apps can also be launched
167  * into separate windows.
168  *
169  * Note that when a new window is used, the returned deferred will callback
170  * immediately with a `null` app instance (normal app communication is not
171  * possible cross-window).
172  *
173  * @param {String/Object} app the app id, class name or launcher
174  * @param {Widget/Window} [container] the app container widget or
175  *            window, defaults to create a new pane in the app tab
176  *            container
177  *
178  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
179  *         callback with the app instance (or `null` if not available)
180  *
181  * @example
182  * // Starts the help app in a new tab
183  * RapidContext.App.startApp('help');
184  *
185  * // Starts the help app in a new window
186  * RapidContext.App.startApp('help', window.open());
187  */
188 RapidContext.App.startApp = function (app, container) {
189
190     // Setup variables and find app launcher
191     var launcher = RapidContext.App.findApp(app);
192     if (launcher == null) {
193         RapidContext.Log.error("No matching app launcher found", app);
194         throw new Error("No matching app launcher found: " + app);
195     }
196     var instance = null;
197     var instances = RapidContext.App._instances();
198     var startApp = RapidContext.App.findApp("start");
199     var d = MochiKit.Async.wait(0.1);
200     var ui = null;
201
202     // Initialize app UI container
203     launcher.instances = launcher.instances || [];
204     if ($.isWindow(container)) {
205         var url = "rapidcontext/app/" + launcher.id;
206         container.location.href = RapidContext.Util.resolveURI(url);
207         return d;
208     } else if (startApp && startApp.instances && startApp.instances.length) {
209         var opts = { title: launcher.name, closeable: (launcher.launch != "once") };
210         ui = startApp.instances[0].initAppPane(container, opts);
211     } else if (instances.length == 1 && launcher.id != "start") {
212         // Switch from single-app to multi-app mode
213         d = RapidContext.App.startApp("start");
214         d.addCallback(function (instance) {
215             return RapidContext.App.startApp(app, container);
216         });
217         return d;
218     } else {
219         var ui = { root: document.body, overlay: document.body.childNodes[0] };
220     }
221     MochiKit.Signal.connect(ui.root, "onclose", d, "cancel");
222
223     // Load app resources
224     RapidContext.Log.context("RapidContext.App.startApp(" + launcher.id + ")");
225     if (launcher.creator == null) {
226         RapidContext.Log.info("Loading app/" + launcher.id + " resources", launcher);
227         launcher.resource = {};
228         for (var i = 0; i < launcher.resources.length; i++) {
229             var res = launcher.resources[i];
230             var url = RapidContext.App._rebaseUrl(res.url);
231             if (res.type == "code" || res.type == "js" || res.type == "javascript") {
232                 d.addCallback(MochiKit.Base.partial(RapidContext.App.loadScript, url));
233             } else if (res.type == "style" || res.type == "css") {
234                 d.addCallback(MochiKit.Base.partial(RapidContext.App.loadStyles, url));
235             } else if (res.type == "ui") {
236                 d.addCallback(MochiKit.Base.partial(RapidContext.App.loadXML, url));
237                 d.addCallback(RapidContext.App._cbAssign(launcher, "ui"));
238             } else if (res.type == "json" && res.id != null) {
239                 d.addCallback(MochiKit.Base.partial(RapidContext.App.loadJSON, url, null, null));
240                 d.addCallback(RapidContext.App._cbAssign(launcher.resource, res.id));
241             } else if (res.id != null) {
242                 launcher.resource[res.id] = url;
243             }
244         }
245         d.addCallback(function () {
246             launcher.creator = this[launcher.className] || window[launcher.className];
247             if (launcher.creator == null) {
248                 RapidContext.Log.error("App constructor " + launcher.className + " not defined", launcher);
249                 throw new Error("App constructor " + launcher.className + " not defined");
250             }
251             RapidContext.Util.registerFunctionNames(launcher.creator, launcher.className);
252         });
253     }
254
255     // Create app instance, build UI and start app
256     d.addCallback(function () {
257         RapidContext.Log.info("Starting app/" + launcher.id, launcher);
258         var fun = launcher.creator;
259         instance = new fun();
260         launcher.instances.push(instance);
261         var props = MochiKit.Base.setdefault({ ui: ui }, launcher);
262         delete props.creator;
263         delete props.instances;
264         MochiKit.Base.setdefault(instance, props);
265         MochiKit.Signal.disconnectAll(ui.root, "onclose");
266         MochiKit.Signal.connect(ui.root, "onclose",
267                                 MochiKit.Base.partial(RapidContext.App.stopApp, instance));
268         if (launcher.ui != null) {
269             var widgets = RapidContext.UI.buildUI(launcher.ui, ui);
270             MochiKit.DOM.appendChildNodes(ui.root, widgets);
271             RapidContext.Util.resizeElements(ui.root);
272         }
273         ui.overlay.hide();
274         ui.overlay.setAttrs({ message: "Working..." });
275         return RapidContext.App.callApp(instance, "start");
276     });
277
278     // Convert to start app UI (if previously in single-app mode)
279     if (launcher.id == "start" && instances.length == 1) {
280         var elems = MochiKit.Base.extend([], document.body.childNodes);
281         var opts = { title: instances[0].name, closeable: false, background: true };
282         d.addCallback(function () {
283             return RapidContext.App.callApp(instance, "initAppPane", null, opts);
284         });
285         d.addCallback(function (ui) {
286             ui.root.removeAll();
287             ui.root.addAll(elems);
288             RapidContext.Util.resizeElements(ui.root);
289         });
290     }
291
292     // Report errors and return app instance
293     d.addErrback(function (err) {
294         if (err instanceof MochiKit.Async.CancelledError) {
295             // Ignore cancellation errors
296         } else {
297             RapidContext.Log.error("Failed to start app", err);
298             MochiKit.Signal.disconnectAll(ui.root, "onclose");
299             var label = MochiKit.DOM.STRONG(null, "Error: ");
300             MochiKit.DOM.appendChildNodes(ui.root, label, err.message);
301             ui.overlay.hide();
302         }
303         return err;
304     });
305     d.addCallback(function () {
306         return instance;
307     });
308     d.addBoth(function (res) {
309         RapidContext.Log.context(null);
310         return res;
311     });
312     RapidContext.App._addErrbackLogger(d);
313     return d;
314 };
315
316 /**
317  * Stops an app instance. If only the class name or launcher is specified, the
318  * most recently created instance will be stopped.
319  *
320  * @param {String/Object} app the app id, instance, class name or launcher
321  *
322  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
323  *         callback when the app has been stopped
324  */
325 RapidContext.App.stopApp = function (app) {
326     var launcher = RapidContext.App.findApp(app);
327     if (!(launcher && launcher.instances && launcher.instances.length)) {
328         RapidContext.Log.error("No running app instance found", app);
329         throw new Error("No running app instance found");
330     }
331     RapidContext.Log.info("Stopping app " + launcher.name);
332     var pos = MochiKit.Base.findIdentical(launcher.instances, app);
333     if (pos < 0) {
334         app = launcher.instances.pop();
335     } else {
336         launcher.instances.splice(pos, 1);
337     }
338     app.stop();
339     if (app.ui.root != null) {
340         RapidContext.Widget.destroyWidget(app.ui.root);
341     }
342     for (var n in app.ui) {
343         delete app.ui[n];
344     }
345     for (var n in app) {
346         delete app[n];
347     }
348     MochiKit.Signal.disconnectAllTo(app);
349     return MochiKit.Async.wait(0);
350 };
351
352 /**
353  * Performs an asynchronous call to a method in an app. If only the class name
354  * or launcher is specified, the most recently created instance will be used.
355  * If no instance is running, one will be started. Also, before calling the app
356  * method, the app UI will be focused.
357  *
358  * @param {String/Object} app the app id, instance, class name or launcher
359  * @param {String} method the app method name
360  * @param {Mixed} [args] additional parameters sent to method
361  *
362  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
363  *         callback with the result of the call on success
364  */
365 RapidContext.App.callApp = function (app, method) {
366     var args = MochiKit.Base.extend([], arguments, 2);
367     var launcher = RapidContext.App.findApp(app);
368     var d;
369     if (launcher == null) {
370         RapidContext.Log.error("No matching app launcher found", app);
371         throw new Error("No matching app launcher found");
372     }
373     if (!(launcher.instances && launcher.instances.length)) {
374         d = RapidContext.App.startApp(app);
375     } else {
376         var pos = MochiKit.Base.findIdentical(launcher.instances, app);
377         var instance = (pos >= 0) ? app : launcher.instances[launcher.instances.length - 1];
378         d = MochiKit.Async.wait(0, instance);
379         RapidContext.App._addErrbackLogger(d);
380     }
381     d.addCallback(function (instance) {
382         RapidContext.Log.context("RapidContext.App.callApp(" + launcher.id + "," + method + ")");
383         var child = instance.ui.root;
384         var parent = MochiKit.DOM.getFirstParentByTagAndClassName(child, null, "widget");
385         if (parent != null && typeof(parent.selectChild) == "function") {
386             parent.selectChild(child);
387         }
388         var methodName = launcher.className + "." + method;
389         if (instance == null || instance[method] == null) {
390             RapidContext.Log.error("No app method " + methodName + " found");
391             throw new Error("No app method " + methodName + " found");
392         }
393         RapidContext.Log.log("Calling app method " + methodName, args);
394         try {
395             return instance[method].apply(instance, args);
396         } catch (e) {
397             RapidContext.Log.error("In call to " + methodName, e);
398             throw new Error("In call to " + methodName + ": " + e.message);
399         }
400     });
401     d.addBoth(function (res) {
402         RapidContext.Log.context(null);
403         return res;
404     });
405     return d;
406 };
407
408 /**
409  * Performs an asynchronous procedure call. This function returns a deferred
410  * object that will produce either a `callback` or an `errback` depending on
411  * the server response.
412  *
413  * @param {String} name the procedure name
414  * @param {Array} [args] the array of arguments, or `null`
415  *
416  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
417  *         callback with the response data on success
418  */
419 RapidContext.App.callProc = function (name, args) {
420     var params = {};
421     var options = { timeout: 60 };
422
423     // TODO: remove this legacy name conversion
424     if (name.indexOf("RapidContext.") == 0) {
425         name = "System" + name.substring(8);
426     }
427     RapidContext.Log.log("Call request " + name, args);
428     for (var i = 0; args != null && i < args.length; i++) {
429         if (args[i] == null) {
430             params["arg" + i] = "null";
431         } else {
432             params["arg" + i] = JSON.stringify(args[i]);
433         }
434     }
435     var logLevel = RapidContext.Log.level();
436     if (logLevel == "log" || logLevel == "all") {
437         params["system:trace"] = 1;
438     }
439     var d = RapidContext.App.loadJSON("rapidcontext/procedure/" + name, params, options);
440     d.addCallback(function (res) {
441         if (res.trace != null) {
442             RapidContext.Log.log("Server trace " + name, res.trace);
443         }
444         if (res.error != null) {
445             RapidContext.Log.error("Call error " + name, res.error);
446             throw new Error(res.error);
447         } else {
448             RapidContext.Log.log("Call response " + name, res.data);
449         }
450         return res.data;
451     });
452     if (name.indexOf("System.") == 0) {
453         d.addCallback(function (res) {
454             if (res) {
455                 RapidContext.App._Cache.update(name, res);
456             }
457             return res;
458         });
459     }
460     return d;
461 };
462
463 /**
464  * Performs an asynchronous login. This function returns a deferred object
465  * that will produce either a `callback` or an `errback` depending on the
466  * success of the login attempt. If the current session is already bound to a
467  * user, that session will be terminated and a new one will be created. If an
468  * authentication token is specified, the login and password fields are not
469  * used (can be null).
470  *
471  * @param {String} login the user login name or email address
472  * @param {String} password the password to autheticate the user
473  * @param {String} [token] the authentication token to indentify user/password
474  *
475  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
476  *         callback with the response data on success
477  */
478 RapidContext.App.login = function (login, password, token) {
479     var d = MochiKit.Async.wait(0, false);
480     var user = RapidContext.App.user();
481     if (user && user.id) {
482         d.addCallback(RapidContext.App.logout);
483     }
484     if (token) {
485         d.addCallback(function () {
486             var args = [token];
487             return RapidContext.App.callProc("System.Session.AuthenticateToken", args);
488         });
489     } else {
490         if (/@/.test(login)) {
491             d.addCallback(function () {
492                 return RapidContext.App.callProc("System.User.Search", [login]);
493             });
494             d.addCallback(function (user) {
495                 if (user && user.id) {
496                     login = user.id;
497                     return login;
498                 } else {
499                     throw new Error("no user with that email address");
500                 }
501             });
502         }
503         d.addCallback(function () {
504             return RapidContext.App.callProc("System.Session.Current");
505         });
506         d.addCallback(function (session) {
507             var realm = RapidContext.App.status().realm;
508             var hash = CryptoJS.MD5(login + ":" + realm + ":" + password);
509             hash = CryptoJS.MD5(hash.toString() + ":" + session.nonce).toString();
510             var args = [login, session.nonce, hash];
511             return RapidContext.App.callProc("System.Session.Authenticate", args);
512         });
513     }
514     return d;
515 };
516
517 /**
518  * Performs an asyncronous logout. This function terminates the current
519  * session and either reloads the browser window or returns a deferred object
520  * that will produce either a `callback` or an `errback` response.
521  *
522  * @param {Boolean} [reload] the reload browser flag, defaults to true
523  *
524  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
525  *         callback with the response data on success
526  */
527 RapidContext.App.logout = function (reload) {
528     var d = RapidContext.App.callProc("System.Session.Terminate", [null]);
529     if (typeof(reload) === "undefined" || reload) {
530         d.addBoth(function () {
531             window.location.reload();
532         });
533     }
534 };
535
536 /**
537  * Performs an asynchronous HTTP request for a JSON data document and returns a
538  * deferred response. If no request method has been specified, the `POST` or
539  * `GET` methods are chosen depending on whether or not the params argument is
540  * `null`. The request parameters are specified as an object that will be
541  * encoded by the `MochiKit.Base.queryString` function. In addition to the
542  * default options in `MochiKit.Async.doXHR`, this function also accepts a
543  * timeout option for automatic request cancellation.
544  *
545  * Note that this function is unsuitable for loading JavaScript source code,
546  * since using `eval()` will confuse some browser error messages and debuggers
547  * regarding the actual source location.
548  *
549  * @param {String} url the URL to request
550  * @param {Object} [params] the request parameters, or `null`
551  * @param {Object} [options] the request options, or `null`
552  * @config {String} [method] the HTTP method, "GET" or "POST"
553  * @config {Number} [timeout] the timeout in seconds, default is no timeout
554  * @config {Object} [headers] the specific HTTP headers to use
555  * @config {String} [mimeType] the override MIME type, default is
556  *             none
557  *
558  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
559  *         callback with the response text on success
560  */
561 RapidContext.App.loadJSON = function (url, params, options) {
562     var d = RapidContext.App.loadXHR(url, params, options);
563     d.addCallback(function (res) {
564         return JSON.parse(res.responseText);
565     });
566     return d;
567 };
568
569 /**
570  * Performs an asynchronous HTTP request for a text document and returns a
571  * deferred response. If no request method has been specified, the `POST` or
572  * `GET` methods are chosen depending on whether or not the params argument is
573  * `null`. The request parameters are specified as an object that will be
574  * encoded by the `MochiKit.Base.queryString` function. In addition to the
575  * default options in `MochiKit.Async.doXHR`, this function also accepts a
576  * timeout option for automatic request cancellation.
577  *
578  * Note that this function is unsuitable for loading JavaScript source code,
579  * since using `eval()` will confuse some browser error messages and debuggers
580  * regarding the actual source location.
581  *
582  * @param {String} url the URL to request
583  * @param {Object} [params] the request parameters, or `null`
584  * @param {Object} [options] the request options, or `null`
585  * @config {String} [method] the HTTP method, "GET" or "POST"
586  * @config {Number} [timeout] the timeout in seconds, default is no timeout
587  * @config {Object} [headers] the specific HTTP headers to use
588  * @config {String} [mimeType] the override MIME type, default is
589  *             none
590  *
591  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
592  *         callback with the response text on success
593  */
594 RapidContext.App.loadText = function (url, params, options) {
595     var d = RapidContext.App.loadXHR(url, params, options);
596     d.addCallback(function (res) { return res.responseText; });
597     return d;
598 };
599
600 /**
601  * Performs an asynchronous HTTP request for an XML document and returns a
602  * deferred response. If no request method has been specified, the `POST` or
603  * `GET` methods are chosen depending on whether or not the params argument is
604  * `null`. The request parameters are specified as an object that will be
605  * encoded by the `MochiKit.Base.queryString` function. In addition to the
606  * default options in `MochiKit.Async.doXHR`, this function also accepts a
607  * timeout option for automatic request cancellation.
608  *
609  * @param {String} url the URL to request
610  * @param {Object} [params] the request parameters, or `null`
611  * @param {Object} [options] the request options, or `null`
612  * @config {String} [method] the HTTP method, "GET" or "POST"
613  * @config {Number} [timeout] the timeout in seconds, default is no timeout
614  * @config {Object} [headers] the specific HTTP headers to use
615  * @config {String} [mimeType] the override MIME type, default is
616  *             none
617  *
618  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
619  *         callback with the parsed response XML document on success
620  */
621 RapidContext.App.loadXML = function (url, params, options) {
622     var d = RapidContext.App.loadXHR(url, params, options);
623     d.addCallback(function (res) { return res.responseXML; });
624     return d;
625 };
626
627 /**
628  * Performs an asynchronous HTTP request and returns a deferred response. If no
629  * request method has been specified, the `POST` or `GET` methods are chosen
630  * depending on whether or not the `params` argument is `null`. The request
631  * parameters are specified as an object that will be encoded by the
632  * `MochiKit.Base.queryString` function. In addition to the default options in
633  * `MochiKit.Async.doXHR`, this function also accepts a timeout option for
634  * automatic request cancellation.
635  *
636  * Note that this function is unsuitable for loading JavaScript source code,
637  * since using `eval()` will confuse some browser error messages and debuggers
638  * regarding the actual source location.
639  *
640  * @param {String} url the URL to request
641  * @param {Object} [params] the request parameters, or `null`
642  * @param {Object} [options] the request options, or `null`
643  * @config {String} [method] the HTTP method, "GET" or "POST"
644  * @config {Number} [timeout] the timeout in seconds, default is no timeout
645  * @config {Object} [headers] the specific HTTP headers to use
646  * @config {String} [mimeType] the override MIME type, default is
647  *             none
648  *
649  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
650  *         callback with the XMLHttpRequest instance on success
651  */
652 RapidContext.App.loadXHR = function (url, params, options) {
653     options = options || {};
654     if (options.method == "GET" && params != null) {
655         url += "?" + MochiKit.Base.queryString(params);
656     } else if (params != null) {
657         options.method = options.method || "POST";
658         options.headers = options.headers || {};
659         options.headers["Content-Type"] = "application/x-www-form-urlencoded";
660         options.sendContent = MochiKit.Base.queryString(params);
661     } else {
662         options.method = options.method || "GET";
663     }
664     var nonCachedUrl = RapidContext.App._nonCachedUrl(url);
665     RapidContext.Log.log("Starting XHR loading", nonCachedUrl, options);
666     var d = MochiKit.Async.doXHR(nonCachedUrl, options);
667     if (options.timeout) {
668         var canceller = function () {
669             // TODO: Supply error to cancel() instead of this hack, when
670             // supported in MochiKit (#323). This work-around is necessary
671             // due to MochiKit internally using wait() in doXHR().
672             d.results[0].canceller();
673             d.results[0].errback("Timeout on request to " + url);
674         };
675         var timer = MochiKit.Async.callLater(options.timeout, canceller);
676         d.addCallback(function (res) {
677             timer.cancel();
678             return res;
679         });
680     }
681     d.addBoth(function (res) {
682         if (res instanceof Error) {
683             RapidContext.Log.warn("Failed XHR loading", nonCachedUrl, res);
684         } else {
685             RapidContext.Log.log("Completed XHR loading", nonCachedUrl);
686         }
687         return res;
688     });
689     RapidContext.App._addErrbackLogger(d);
690     return d;
691 };
692
693 /**
694  * Loads a JavaScript to the the current page asynchronously and returns a
695  * deferred response. This function is only suitable for loading JavaScript
696  * source code, and not JSON data, since it loads the script by inserting a
697  * `<script>` tag in the document `<head>` tag. All function definitions and
698  * values must therefore be stored to global variables by the script to be
699  * accessible after loading. The deferred callback function will therefore not
700  * provide any data even on successful callback.
701  *
702  * This method of script loading has the advantage that JavaScript debuggers
703  * (such as Firebug) will be able to handle the code properly (error messages,
704  * breakpoints, etc). If the script fails to load due to errors however, the
705  * returned deferred object may fail to `errback` in some cases.
706  *
707  * @param {String} url the URL to the script
708  *
709  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
710  *         callback when the script has been loaded
711  */
712 RapidContext.App.loadScript = function (url) {
713     var absoluteUrl = RapidContext.Util.resolveURI(url);
714     var selector1 = 'script[src^="' + url + '"]';
715     var selector2 = 'script[src^="' + absoluteUrl + '"]';
716     var elems = MochiKit.Selector.findDocElements(selector1, selector2);
717     if (elems.length > 0) {
718         RapidContext.Log.log("Script already loaded, skipping", url);
719         return MochiKit.Async.wait(0);
720     }
721     RapidContext.Log.log("Starting script loading", url);
722     var d = MochiKit.Async.loadScript(RapidContext.App._nonCachedUrl(url));
723     d.addCallback(function () {
724         RapidContext.Log.log("Completed loading script", url);
725     });
726     d.addErrback(function (e) {
727         RapidContext.Log.warn("Failed loading script", url + ": " + e.message);
728         return e;
729     });
730     RapidContext.App._addErrbackLogger(d);
731     return d;
732 };
733
734 /**
735  * Loads a CSS stylesheet to the the current page asynchronously and returns a
736  * deferred response. The stylesheet is loaded by inserting a `<link>` tag in
737  * the document head tag, which means that the deferred callback function will
738  * not be provided with any data.
739  *
740  * @param {String} url the URL to the stylesheet
741  *
742  * @return {Deferred} a `MochiKit.Async.Deferred` object that will
743  *         callback when the stylesheet has been loaded
744  */
745 RapidContext.App.loadStyles = function (url) {
746     function findStylesheet(url) {
747         var styles = document.styleSheets;
748         for (var i = 0; i < styles.length; i++) {
749             if (MochiKit.Text.startsWith(url, styles[i].href)) {
750                 return styles[i];
751             }
752         }
753         return null;
754     }
755     function isStylesheetLoaded(url, absoluteUrl) {
756         var sheet = findStylesheet(url) || findStylesheet(absoluteUrl);
757         return !!(sheet && sheet.cssRules && sheet.cssRules.length);
758     }
759     var absoluteUrl = RapidContext.Util.resolveURI(url);
760     if (isStylesheetLoaded(url, absoluteUrl)) {
761         RapidContext.Log.log("Stylesheet already loaded, skipping", url);
762         return MochiKit.Async.wait(0);
763     }
764     RapidContext.Log.log("Starting stylesheet loading", url);
765     var loadUrl = RapidContext.App._nonCachedUrl(url);
766     var link = MochiKit.DOM.LINK({ rel: "stylesheet", type: "text/css", href: loadUrl });
767     document.getElementsByTagName("head")[0].appendChild(link);
768     var d = new MochiKit.Async.Deferred();
769     var img = MochiKit.DOM.IMG();
770     img.onerror = d.callback.bind(d, 'URL loading finished');
771     img.src = loadUrl;
772     d.addBoth(function () {
773         if (!isStylesheetLoaded(url, absoluteUrl)) {
774             // Add 250 ms delay after URL loading to allow CSS parsing for
775             // browsers needing it (WebKit, Safari, Chrome)
776             return MochiKit.Async.wait(0.25);
777         }
778     });
779     d.addBoth(function () {
780         if (isStylesheetLoaded(url, absoluteUrl)) {
781             RapidContext.Log.log("Completed loading stylesheet", url);
782         } else {
783             RapidContext.Log.warn("Failed loading stylesheet", url);
784             throw new URIError("Failed loading stylesheet " + url, absoluteUrl);
785         }
786     });
787     RapidContext.App._addErrbackLogger(d);
788     return d;
789 }
790
791 /**
792  * Downloads a file to the user desktop. This works by creating a new
793  * window or an inner frame which downloads the file from the server.
794  * Due to `Content-Disposition` headers being set on the server, the
795  * web browser will popup a dialog for the user to save the file.
796  * This function can also be used for saving a file that doesn't
797  * exist by first posting the file (text) content to the server.
798  *
799  * @param {String} url the URL or filename to download
800  * @param {String} [data] the optional file data (if not available
801  *            on the server-side)
802  */
803 RapidContext.App.downloadFile = function (url, data) {
804     if (data == null) {
805         url = url + ((url.indexOf("?") < 0) ? "?" : "&") + "download";
806         var attrs = { src: url, border: "0", frameborder: "0",
807                       height: "0", width: "0" };
808         var iframe = MochiKit.DOM.createDOM("iframe", attrs);
809         document.body.appendChild(iframe);
810     } else {
811         var name = MochiKit.DOM.INPUT({ name: "fileName", value: url });
812         var file = MochiKit.DOM.INPUT({ name: "fileData", value: data });
813         var flag = MochiKit.DOM.INPUT({ name: "download", value: "1" });
814         var attrs = { action: "rapidcontext/download", method: "POST",
815                       target: "_blank", style: { display: "none" } };
816         var form = MochiKit.DOM.FORM(attrs, name, file, flag);
817         document.body.appendChild(form);
818         form.submit();
819     }
820 };
821
822 /**
823  * Returns a new relative URL adapted for a non-standard base path.
824  *
825  * @param {String} url the URL to modify
826  *
827  * @return {String} the rebased URL
828  */
829 RapidContext.App._rebaseUrl = function (url) {
830     url = url || "";
831     if (RapidContext._basePath) {
832         if (url.indexOf(RapidContext._basePath) == 0) {
833             url = url.substring(RapidContext._basePath.length);
834         } else if (url.indexOf(":") < 0) {
835             url = "rapidcontext/files/" + url;
836         }
837     }
838     return url;
839 }
840
841 /**
842  * Returns a non-cacheable version of the specified URL. This
843  * function will add a request query parameter consisting of the
844  * current time in milliseconds. Most web servers will ignore this
845  * additional parameter, but due to the URL containing a unique
846  * value, the web browser will not use its cache for the content.
847  *
848  * @param {String} url the URL to modify
849  *
850  * @return {String} the URL including the current timestamp
851  */
852 RapidContext.App._nonCachedUrl = function (url) {
853     var timestamp = new Date().getTime() % 100000;
854     return url + ((url.indexOf("?") < 0) ? "?" : "&") + timestamp;
855 };
856
857 /**
858  * Adds an error logger to a `MochiKit.Async.Deferred` object. The
859  * logger will actually be added in the next JavaScript event loop,
860  * ensuring that any other callbacks or handlers have already been
861  * added to the deferred object. This is useful for catching
862  * programmer errors and similar that cause exceptions inside
863  * callback functions.
864  *
865  * @param {Deferred} d the `MochiKit.Async.Deferred` object to modify
866  */
867 RapidContext.App._addErrbackLogger = function (d) {
868     var logger = function (err) {
869         if (!d.chained) {
870             // TODO: Handle MochiKit.Async.CancelledError here?
871             RapidContext.Log.warn("Unhandled error in deferred", err);
872         }
873         return err;
874     };
875     var adder = function () {
876         if (!d.chained) {
877             d.addErrback(logger);
878         }
879     };
880     MochiKit.Async.callLater(0, adder);
881 };
882
883 RapidContext.App._Callback = {
884     nextId: MochiKit.Base.counter(),
885     create: function () {
886         var id = "cb" + this.nextId();
887         var d = new MochiKit.Async.Deferred();
888         var func = MochiKit.Base.bind("handle", this, id, d);
889         func.displayName = "RapidContext.App._Callback." + id;
890         this[id] = d.func = func;
891         return d;
892     },
893     handle: function (id, d, data) {
894         delete this[id];
895         if (data instanceof Error) {
896             d.errback(data);
897         } else {
898             d.callback(data);
899         }
900     }
901 }
902
903 /**
904  * The application data cache. Contains the most recently retrieved
905  * data for some commonly used objects in the execution environment.
906  */
907 RapidContext.App._Cache = {
908     status: null,
909     user: null,
910     apps: [],
911     // Compares two object on the 'id' property
912     compareId: function (a, b) {
913         // TODO: replace with MochiKit.Base.keyComparator once #331 is fixed
914         if (a == null || b == null) {
915             return MochiKit.Base.compare(a, b);
916         } else {
917             return this._cmpId(a, b);
918         }
919     },
920     // Object comparator for 'id' property
921     _cmpId: MochiKit.Base.keyComparator("id"),
922     // Builds an icon DOM node from a resource
923     _buildIcon: function (res) {
924         if (res.url) {
925             var url = RapidContext.App._rebaseUrl(res.url);
926             return $("<img/>").attr({ src: url })[0];
927         } else if (res.html) {
928             var node = $("<span/>").html(res.html)[0];
929             return node.childNodes.length === 1 ? node.childNodes[0] : node;
930         } else if (res["class"]) {
931             return $("<i/>").addClass(res["class"])[0];
932         } else {
933             return null;
934         }
935     },
936     // Updates the cache data with the results from a procedure.
937     update: function (proc, data) {
938         switch (proc) {
939         case "System.Status":
940             // TODO: use deep clone
941             data = MochiKit.Base.update(null, data);
942             this.status = data;
943             RapidContext.Log.log("Updated cached status", this.status);
944             break;
945         case "System.Session.Current":
946             if (this.compareId(this.user, data.user) != 0) {
947                 // TODO: use deep clone
948                 data = MochiKit.Base.update(null, data.user);
949                 if (data.name != "") {
950                     data.longName = data.name + " (" + data.id + ")";
951                 } else {
952                     data.longName = data.id;
953                 }
954                 this.user = data;
955                 RapidContext.Log.log("Updated cached user", this.user);
956             }
957             break;
958         case "System.App.List":
959             var idxs = {};
960             for (var i = 0; i < this.apps.length; i++) {
961                 idxs[this.apps[i].id] = i;
962             }
963             for (var i = 0; i < data.length; i++) {
964                 // TODO: use deep clone
965                 var launcher = MochiKit.Base.update(null, data[i]);
966                 launcher.launch = launcher.launch || "manual";
967                 if (!(launcher.resources instanceof Array)) {
968                     launcher.resources = [];
969                 }
970                 for (var j = 0; j < launcher.resources.length; j++) {
971                     if (launcher.resources[j].type == "icon") {
972                         launcher.icon = this._buildIcon(launcher.resources[j]);
973                     }
974                 }
975                 if (!launcher.icon) {
976                     var cssClass = "fa fa-4x fa-question-circle unimportant";
977                     launcher.icon = this._buildIcon({ "class": cssClass });
978                 }
979                 if (launcher.className == null) {
980                     RapidContext.Log.error("App missing 'className' property", launcher);
981                 }
982                 var pos = idxs[launcher.id];
983                 if (pos >= 0) {
984                     delete idxs[launcher.id];
985                     MochiKit.Base.update(this.apps[pos], launcher);
986                 } else {
987                     this.apps.push(launcher);
988                 }
989             }
990             // TODO: use separate list of app instances...
991             var arr = MochiKit.Base.values(idxs).reverse();
992             for (var i = 0; i < arr.length; i++) {
993                 var pos = arr[i];
994                 if (this.apps[pos].instances == null) {
995                     this.apps.splice(pos, 1);
996                 }
997             }
998             RapidContext.Log.log("Updated cached apps", this.apps);
999             break;
1000         }
1001     }
1002 };
1003