Source RapidContext_App.js

1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2023 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 * The base RapidContext namespace.
17 * @namespace RapidContext
18 * @private
19 */
20if (typeof(RapidContext) == "undefined") {
21    RapidContext = {};
22}
23
24/**
25 * Provides functions for application bootstrap and server communication.
26 * @namespace RapidContext.App
27 */
28if (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 {Promise} a promise that will resolve when initialization has
41 *         either completed or failed
42 */
43RapidContext.App.init = function (app) {
44    // Setup libraries
45    RapidContext.Log.context("RapidContext.App.init()");
46    console.info("Initializing RapidContext");
47    if (!RapidContext.Browser.isSupported()) {
48        var el = document.getElementById("unsupported-browser");
49        el.className = el.className.replace("hidden", "");
50        return Promise.reject(new Error("Unsupported browser"));
51    }
52
53    // Setup UI
54    RapidContext.Util.registerSizeConstraints(document.body, "100%-20", "100%-20");
55    var resizer = () => RapidContext.Util.resizeElements(document.body);
56    MochiKit.Signal.connect(window, "onresize", resizer);
57    document.body.innerHTML = "";
58    document.body.append(new RapidContext.Widget.Overlay({ message: "Loading..." }));
59
60    // Load platform data (into cache)
61    var cachedData = [
62        RapidContext.App.callProc("system/status"),
63        RapidContext.App.callProc("system/session/current"),
64        RapidContext.App.callProc("system/app/list")
65    ];
66
67    // Launch app
68    return Promise.all(cachedData)
69        .then(function () {
70            const hourly = 60 * 60 * 1000;
71            setInterval(() => RapidContext.App.callProc("system/session/current"), hourly);
72            if (app && app !== "start") {
73                return RapidContext.App.startApp(app || "start").catch(function (err) {
74                    RapidContext.UI.showError(err);
75                    return RapidContext.App.startApp("start");
76                });
77            } else {
78                return RapidContext.App.startApp("start");
79            }
80        })
81        .catch(function (err) {
82            RapidContext.UI.showError(err);
83            return Promise.reject(err);
84        })
85        .finally(function () {
86            RapidContext.Log.context(null);
87        });
88};
89
90/**
91 * Returns an object with status information about the platform and
92 * currently loaded environment. The object returned is a copy of
93 * the internal data structure and can be modified without affecting
94 * the real data.
95 *
96 * @return {Object} the status data object
97 */
98RapidContext.App.status = function () {
99    return Object.assign({}, RapidContext.App._Cache.status);
100};
101
102/**
103 * Returns an object with information about the user. The object
104 * returned is a copy of the internal data structure and can be
105 * modified without affecting the real data.
106 *
107 * @return {Object} the user data object
108 */
109RapidContext.App.user = function () {
110    return Object.assign({}, RapidContext.App._Cache.user);
111};
112
113/**
114 * Returns an array with app launchers. The data returned is an
115 * internal data structure and should not be modified.
116 *
117 * @return {Array} the loaded app launchers (read-only)
118 */
119RapidContext.App.apps = function () {
120    return Object.values(RapidContext.App._Cache.apps);
121};
122
123/**
124 * Returns an array with running app instances.
125 *
126 * @return {Array} the array of app instances
127 */
128RapidContext.App._instances = function () {
129    var res = [];
130    var apps = RapidContext.App.apps();
131    for (var i = 0; i < apps.length; i++) {
132        res = res.concat(apps[i].instances || []);
133    }
134    return res;
135};
136
137/**
138 * Finds the app launcher from an app instance, class name or
139 * launcher. In the last case, the matching cached launcher will be
140 * returned.
141 *
142 * @param {string|Object} app the app id, instance, class name or
143 *        launcher
144 *
145 * @return {Object} the read-only app launcher, or
146 *         `null` if not found
147 */
148RapidContext.App.findApp = function (app) {
149    if (app == null) {
150        return null;
151    }
152    var apps = RapidContext.App.apps();
153    for (var i = 0; i < apps.length; i++) {
154        var l = apps[i];
155        if (l.id == app || l.className == app || l.id == app.id) {
156            return l;
157        }
158    }
159    return null;
160};
161
162/**
163 * Creates and starts an app instance. Normally apps are started in the default
164 * app tab container or in a provided widget DOM node. By specifying a newly
165 * created window object as the parent container, apps can also be launched
166 * into separate windows.
167 *
168 * Note that when a new window is used, the returned deferred will callback
169 * immediately with a `null` app instance (normal app communication is not
170 * possible cross-window).
171 *
172 * @param {string|Object} app the app id, class name or launcher
173 * @param {Widget/Window} [container] the app container widget or
174 *            window, defaults to create a new pane in the app tab
175 *            container
176 *
177 * @return {Promise} a `RapidContext.Async` promise that will
178 *         resolve when the app has launched
179 *
180 * @example
181 * // Starts the help app in a new tab
182 * RapidContext.App.startApp('help');
183 *
184 * // Starts the help app in a new window
185 * RapidContext.App.startApp('help', window.open());
186 */
187RapidContext.App.startApp = function (app, container) {
188    function loadResource(launcher, res) {
189        if (res.type == "code") {
190            return RapidContext.App.loadScript(res.url);
191        } else if (res.type == "module") {
192            return import(new URL(res.url, document.baseURI)).then(function (mod) {
193                launcher.creator = launcher.creator || mod["default"] || mod["create"];
194            });
195        } else if (res.type == "style") {
196            return RapidContext.App.loadStyles(res.url);
197        } else if (res.type == "ui") {
198            return RapidContext.App.loadXML(res.url).then(function (node) {
199                launcher.ui = node;
200            });
201        } else if (res.type == "json" && res.id != null) {
202            return RapidContext.App.loadJSON(res.url).then(function (data) {
203                launcher.resource[res.id] = data;
204            });
205        } else {
206            if (res.id != null) {
207                launcher.resource[res.id] = res.url;
208            }
209            return Promise.resolve(res);
210        }
211    }
212    function load(launcher) {
213        console.info("Loading app/" + launcher.id + " resources", launcher);
214        launcher.resource = {};
215        var promises = launcher.resources.map((res) => loadResource(launcher, res));
216        return Promise.all(promises).then(function () {
217            launcher.creator = launcher.creator || window[launcher.className];
218            if (launcher.creator == null) {
219                console.error("App constructor " + launcher.className + " not defined", launcher);
220                throw new Error("App constructor " + launcher.className + " not defined");
221            }
222        });
223    }
224    function buildUI(parent, ids, ui) {
225        var root = ui.documentElement;
226        for (var i = 0; i < root.attributes.length; i++) {
227            var attr = root.attributes[i];
228            if (typeof(parent.setAttrs) === "function") {
229                parent.setAttrs({ [attr.name]: attr.value });
230            } else if (attr.name === "class") {
231                attr.value.split(/\s+/g).forEach(function (cls) {
232                    parent.classList.add(cls);
233                });
234            } else {
235                parent.setAttribute(attr.name, attr.value);
236            }
237        }
238        parent.append(...RapidContext.UI.buildUI(ui, ids));
239        RapidContext.Util.resizeElements(parent);
240    }
241    function launch(launcher, ui) {
242        RapidContext.Log.context("RapidContext.App.startApp(" + launcher.id + ")");
243        return RapidContext.Async.wait(0)
244            .then(() => launcher.creator ? true : load(launcher))
245            .then(function () {
246                console.info("Starting app/" + launcher.id, launcher);
247                /* eslint new-cap: "off" */
248                var instance = new launcher.creator();
249                launcher.instances.push(instance);
250                var props = MochiKit.Base.setdefault({ ui: ui }, launcher);
251                delete props.creator;
252                delete props.instances;
253                MochiKit.Base.setdefault(instance, props);
254                MochiKit.Signal.disconnectAll(ui.root, "onclose");
255                var halt = () => RapidContext.App.stopApp(instance);
256                MochiKit.Signal.connect(ui.root, "onclose", halt);
257                if (launcher.ui != null) {
258                    buildUI(ui.root, ui, launcher.ui);
259                }
260                ui.overlay.hide();
261                ui.overlay.setAttrs({ message: "Working..." });
262                return RapidContext.App.callApp(instance, "start");
263            })
264            .catch(function (e) {
265                console.error("Failed to start app", e);
266                MochiKit.Signal.disconnectAll(ui.root, "onclose");
267                let div = document.createElement("div");
268                div.append(String(e));
269                ui.root.append(div);
270                if (e.url) {
271                    let link = document.createElement("a");
272                    link.className = "block mt-2";
273                    link.setAttribute("href", e.url);
274                    link.append(e.url);
275                    ui.root.append(link);
276                }
277                ui.overlay.hide();
278                return Promise.reject(e);
279            })
280            .finally(function () {
281                RapidContext.Log.context(null);
282            });
283    }
284    function moveAppToStart(instance, elems) {
285        var start = RapidContext.App.findApp("start").instances[0];
286        var opts = { title: instance.name, closeable: false, background: true };
287        var ui = start.initAppPane(null, opts);
288        ui.root.removeAll();
289        ui.root.addAll(elems);
290        RapidContext.Util.resizeElements(ui.root);
291    }
292    return new RapidContext.Async(function (resolve, reject) {
293        var launcher = RapidContext.App.findApp(app);
294        if (launcher == null) {
295            var msg = "No matching app launcher found";
296            console.error(msg, app);
297            throw new Error([msg, ": ", app].join(""));
298        }
299        var instances = RapidContext.App._instances();
300        var start = RapidContext.App.findApp("start");
301        if ($.isWindow(container)) {
302            // Launch app into separate window/tab
303            var href = "rapidcontext/app/" + launcher.id;
304            container.location.href = new URL(href, document.baseURI).toString();
305            resolve();
306        } else if (start && start.instances && start.instances.length > 0) {
307            // Launch app into start app tab
308            var paneOpts = { title: launcher.name, closeable: (launcher.launch != "once") };
309            var paneUi = start.instances[0].initAppPane(container, paneOpts);
310            resolve(launch(launcher, paneUi));
311        } else if (instances.length > 0) {
312            // Switch from single-app to multi-app mode
313            var elems = Array.from(document.body.childNodes);
314            var overlay = new RapidContext.Widget.Overlay({ message: "Loading..." });
315            document.body.insertBefore(overlay, document.body.childNodes[0]);
316            let ui = { root: document.body, overlay: overlay };
317            var move = () => moveAppToStart(instances[0], elems);
318            var recall = () => (launcher.id === "start") ? true : RapidContext.App.startApp(launcher.id);
319            resolve(launch(start, ui).then(move).then(recall));
320        } else {
321            // Launch single-app mode
322            var ui = { root: document.body, overlay: document.body.childNodes[0] };
323            resolve(launch(launcher, ui));
324        }
325    });
326};
327
328/**
329 * Stops an app instance. If only the class name or launcher is specified, the
330 * most recently created instance will be stopped.
331 *
332 * @param {string|Object} app the app id, instance, class name or launcher
333 *
334 * @return {Promise} a `RapidContext.Async` promise that will
335 *         resolve when the app has been stopped
336 */
337RapidContext.App.stopApp = function (app) {
338    return new RapidContext.Async(function (resolve, reject) {
339        var launcher = RapidContext.App.findApp(app);
340        if (!launcher || launcher.instances.length <= 0) {
341            var msg = "No running app instance found";
342            console.error(msg, app);
343            throw new Error([msg, ": ", app].join(""));
344        }
345        console.info("Stopping app " + launcher.name);
346        var pos = launcher.instances.indexOf(app);
347        if (pos < 0) {
348            app = launcher.instances.pop();
349        } else {
350            launcher.instances.splice(pos, 1);
351        }
352        app.stop();
353        if (app.ui.root != null) {
354            RapidContext.Widget.destroyWidget(app.ui.root);
355        }
356        for (var k in app.ui) {
357            delete app.ui[k];
358        }
359        for (var n in app) {
360            delete app[n];
361        }
362        MochiKit.Signal.disconnectAllTo(app);
363        resolve();
364    });
365};
366
367/**
368 * Performs an asynchronous call to a method in an app. If only the class name
369 * or launcher is specified, the most recently created instance will be used.
370 * If no instance is running, one will be started. Also, before calling the app
371 * method, the app UI will be focused.
372 *
373 * @param {string|Object} app the app id, instance, class name or launcher
374 * @param {string} method the app method name
375 * @param {Mixed} [args] additional parameters sent to method
376 *
377 * @return {Promise} a `RapidContext.Async` promise that will
378 *         resolve with the result of the call on success
379 */
380RapidContext.App.callApp = function (app, method) {
381    var args = Array.from(arguments).slice(2);
382    return new RapidContext.Async(function (resolve, reject) {
383        var launcher = RapidContext.App.findApp(app);
384        if (launcher == null) {
385            var msg = "No matching app launcher found";
386            console.error(msg, app);
387            throw new Error([msg, ": ", app].join(""));
388        }
389        var exists = launcher.instances.length > 0;
390        var promise = exists ? RapidContext.Async.wait(0) : RapidContext.App.startApp(app);
391        promise = promise.then(function () {
392            RapidContext.Log.context("RapidContext.App.callApp(" + launcher.id + "," + method + ")");
393            var pos = launcher.instances.indexOf(app);
394            var instance = (pos >= 0) ? app : launcher.instances[launcher.instances.length - 1];
395            var child = instance.ui.root;
396            var parent = child.parentNode.closest(".widget");
397            if (parent != null && typeof(parent.selectChild) == "function") {
398                parent.selectChild(child);
399            }
400            var methodName = launcher.className + "." + method;
401            if (instance[method] == null) {
402                var msg = "No app method " + methodName + " found";
403                console.error(msg);
404                throw new Error(msg);
405            }
406            console.log("Calling app method " + methodName, args);
407            try {
408                return instance[method].apply(instance, args);
409            } catch (e) {
410                var reason = "Caught error in " + methodName;
411                console.error(reason, e);
412                throw new Error(reason + ": " + e.toString(), { cause: e });
413            }
414        }).finally(function () {
415            RapidContext.Log.context(null);
416        });
417        resolve(promise);
418    });
419};
420
421/**
422 * Performs an asynchronous procedure call.
423 *
424 * @param {string} name the procedure name
425 * @param {Array} [args] the array of arguments, or `null`
426 * @param {Object} [opts] the procedure call options
427 * @param {boolean} [opts.session] the HTTP session required flag
428 * @param {boolean} [opts.trace] the procedure call trace flag
429 * @param {number} [opts.timeout] the timeout in milliseconds, default is 60s
430 *
431 * @return {Promise} a `RapidContext.Async` promise that will
432 *         resolve with the response data on success
433 */
434RapidContext.App.callProc = function (name, args, opts) {
435    args = args || [];
436    opts = opts || {};
437    // TODO: remove this legacy name conversion
438    if (name.startsWith("RapidContext.")) {
439        console.warn("deprecated: " + name + " procedure called, use new system/* name instead");
440        name = "System" + name.substring(8);
441    }
442    console.log("Call request " + name, args);
443    let params = RapidContext.Data.object(
444        args.map((val, idx) => ["arg" + idx, RapidContext.Encode.toJSON(val)])
445    );
446    params["system:session"] = !!opts.session;
447    params["system:trace"] = !!opts.trace || ["all", "log"].includes(RapidContext.Log.level());
448    let url = "rapidcontext/procedure/" + name;
449    let options = { method: "POST", timeout: opts.timeout || 60000 };
450    return RapidContext.App.loadJSON(url, params, options).then(function (res) {
451        if (res.trace != null) {
452            console.log(name + " trace:", res.trace);
453        }
454        if (res.error != null) {
455            console.info(name + " error:", res.error);
456            throw new Error(res.error);
457        } else {
458            console.log(name + " response:", res.data);
459        }
460        if (name.startsWith("system/") && !res.error) {
461            RapidContext.App._Cache.update(name, res.data);
462        }
463        return res.data;
464    });
465};
466
467/**
468 * Performs an asynchronous login. If the current session is already bound to a
469 * user, that session will be terminated and a new one will be created. If an
470 * authentication token is specified, the login and password fields are not
471 * used (can be null).
472 *
473 * @param {string} login the user login name or email address
474 * @param {string} password the password to authenticate the user
475 * @param {string} [token] the authentication token to identify user/password
476 *
477 * @return {Promise} a `RapidContext.Async` promise that resolves when
478 *         the authentication has either succeeded or failed
479 */
480RapidContext.App.login = function (login, password, token) {
481    function searchLogin() {
482        var proc = "system/user/search";
483        return RapidContext.App.callProc(proc, [login]).then(function (user) {
484            if (user && user.id) {
485                return login = user.id;
486            } else {
487                throw new Error("no user with that email address");
488            }
489        });
490    }
491    function getNonce() {
492        let proc = "system/session/current";
493        let opts = { session: true };
494        return RapidContext.App.callProc(proc, [], opts).then(function (session) {
495            return session.nonce;
496        });
497    }
498    function passwordAuth(nonce) {
499        var realm = RapidContext.App.status().realm;
500        var hash = CryptoJS.MD5(login + ":" + realm + ":" + password);
501        hash = CryptoJS.MD5(hash.toString() + ":" + nonce).toString();
502        var args = [login, nonce, hash];
503        return RapidContext.App.callProc("system/session/authenticate", args);
504    }
505    function tokenAuth() {
506        return RapidContext.App.callProc("system/session/authenticateToken", [token]);
507    }
508    function verifyAuth(res) {
509        if (!res.success || res.error) {
510            console.info("login failed", login, res.error);
511            throw new Error(res.error || "authentication failed");
512        }
513    }
514    var promise = RapidContext.Async.wait(0);
515    var user = RapidContext.App.user();
516    if (user && user.id) {
517        promise = promise.then(RapidContext.App.logout);
518    }
519    if (token) {
520        return promise.then(tokenAuth).then(verifyAuth);
521    } else {
522        if (/@/.test(login)) {
523            promise = promise.then(searchLogin);
524        }
525        return promise.then(getNonce).then(passwordAuth).then(verifyAuth);
526    }
527};
528
529/**
530 * Performs an asyncronous logout. This function terminates the current
531 * session and either reloads the browser window or returns a deferred object
532 * that will produce either a `callback` or an `errback` response.
533 *
534 * @param {boolean} [reload=true] the reload browser flag
535 *
536 * @return {Promise} a `RapidContext.Async` promise that will
537 *         resolve when user is logged out
538 */
539RapidContext.App.logout = function (reload) {
540    var promise = RapidContext.App.callProc("system/session/terminate", [null]);
541    if (reload !== false) {
542        promise.then(() => window.location.reload());
543    }
544    return promise;
545};
546
547/**
548 * Performs an asynchronous HTTP request and parses the JSON response. The
549 * request parameters are automatically encoded to query string or JSON format,
550 * depending on the `Content-Type` header. The parameters will be sent either
551 * in the URL or as the request payload (depending on the HTTP `method`).
552 *
553 * @param {string} url the URL to request
554 * @param {Object} [params] the request parameters, or `null`
555 * @param {Object} [opts] the request options, or `null`
556 * @param {string} [opts.method] the HTTP method, default is `GET`
557 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
558 * @param {Object} [opts.headers] the specific HTTP headers to use
559 *
560 * @return {Promise} a `RapidContext.Async` promise that will
561 *         resolve with the parsed response JSON on success
562 */
563RapidContext.App.loadJSON = function (url, params, opts) {
564    opts = { responseType: "json", ...opts };
565    return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
566};
567
568/**
569 * Performs an asynchronous HTTP request for a text document. The request
570 * parameters are automatically encoded to query string or JSON format,
571 * depending on the `Content-Type` header. The parameters will be sent either
572 * in the URL or as the request payload (depending on the HTTP `method`).
573 *
574 * @param {string} url the URL to request
575 * @param {Object} [params] the request parameters, or `null`
576 * @param {Object} [opts] the request options, or `null`
577 * @param {string} [opts.method] the HTTP method, "GET" or "POST"
578 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
579 * @param {Object} [opts.headers] the specific HTTP headers to use
580 *
581 * @return {Promise} a `RapidContext.Async` promise that will
582 *         resolve with the response text on success
583 */
584RapidContext.App.loadText = function (url, params, opts) {
585    opts = { responseType: "text", ...opts };
586    return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
587};
588
589/**
590 * Performs an asynchronous HTTP request for an XML document. The request
591 * parameters are automatically encoded to query string or JSON format,
592 * depending on the `Content-Type` header. The parameters will be sent either
593 * in the URL or as the request payload (depending on the HTTP `method`).
594 *
595 * @param {string} url the URL to request
596 * @param {Object} [params] the request parameters, or `null`
597 * @param {Object} [opts] the request options, or `null`
598 * @param {string} [opts.method] the HTTP method, "GET" or "POST"
599 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
600 * @param {Object} [opts.headers] the specific HTTP headers to use
601 *
602 * @return {Promise} a `RapidContext.Async` promise that will
603 *         resolve with the parsed response XML document on success
604 */
605RapidContext.App.loadXML = function (url, params, opts) {
606    opts = { responseType: "document", ...opts };
607    return RapidContext.App.loadXHR(url, params, opts).then((xhr) => xhr.response);
608};
609
610/**
611 * Performs an asynchronous HTTP request. The request parameters are
612 * automatically encoded to query string or JSON format, depending on the
613 * `Content-Type` header. The parameters will be sent either in the URL or as
614 * the request payload (depending on the HTTP `method`).
615 *
616 * @param {string} url the URL to request
617 * @param {Object} [params] the request parameters, or `null`
618 * @param {Object} [opts] the request options, or `null`
619 * @param {string} [opts.method] the HTTP method, default is `GET`
620 * @param {number} [opts.timeout] the timeout in milliseconds, default is 30s
621 * @param {Object} [opts.headers] the specific HTTP headers to use
622 *
623 * @return {Promise} a `RapidContext.Async` promise that will
624 *         resolve with the XMLHttpRequest instance on success
625 */
626RapidContext.App.loadXHR = function (url, params, opts) {
627    opts = { method: "GET", headers: {}, timeout: 30000, ...opts };
628    opts.timeout = (opts.timeout < 1000) ? opts.timeout * 1000 : opts.timeout;
629    var hasBody = params && ["PATCH", "POST", "PUT"].includes(opts.method);
630    var hasJsonBody = opts.headers["Content-Type"] === "application/json";
631    if (!hasBody) {
632        url += params ? "?" + RapidContext.Encode.toUrlQuery(params) : "";
633    } else if (params && hasBody && hasJsonBody) {
634        opts.body = RapidContext.Encode.toJSON(params);
635    } else if (params && hasBody) {
636        opts.headers["Content-Type"] = "application/x-www-form-urlencoded";
637        opts.body = RapidContext.Encode.toUrlQuery(params);
638    }
639    console.log("Starting XHR loading", url, opts);
640    return RapidContext.Async.xhr(url, opts).then(
641        function (res) {
642            console.log("Completed XHR loading", url);
643            return res;
644        },
645        function (err) {
646            console.warn("Failed XHR loading", url, err);
647            return Promise.reject(err);
648        }
649    );
650};
651
652/**
653 * Loads a JavaScript to the the current page. The script is loaded by
654 * inserting a `<script>` tag in the document `<head>`. Function definitions
655 * and values must therefore be stored to global variables by the script to
656 * become accessible after loading. If the script is already loaded, the
657 * promise will resolve immediately.
658 *
659 * @param {string} url the URL to the script
660 *
661 * @return {Promise} a `RapidContext.Async` promise that will
662 *         resolve when the script has loaded
663 *
664 * @see Use dynamic `import()` instead, if supported by the environment.
665 */
666RapidContext.App.loadScript = function (url) {
667    var selector = ["script[src*='", url, "']"].join("");
668    if (document.querySelectorAll(selector).length > 0) {
669        console.log("script already loaded, skipping", url);
670        return RapidContext.Async.wait(0);
671    } else {
672        console.log("loading script", url);
673        return RapidContext.Async.script(url);
674    }
675};
676
677/**
678 * Loads a CSS stylesheet to the the current page asynchronously. The
679 * stylesheet is loaded by inserting a `<link>` tag in the document `head`.
680 * If the stylesheet is already loaded, the promise will resolve immediately.
681 *
682 * @param {string} url the URL to the stylesheet
683 *
684 * @return {Promise} a `RapidContext.Async` promise that will
685 *         callback when the stylesheet has been loaded
686 */
687RapidContext.App.loadStyles = function (url) {
688    var selector = ["link[href*='", url, "']"].join("");
689    if (document.querySelectorAll(selector).length > 0) {
690        console.log("stylesheet already loaded, skipping", url);
691        return RapidContext.Async.wait(0);
692    } else {
693        console.log("loading stylesheet", url);
694        return RapidContext.Async.css(url);
695    }
696};
697
698/**
699 * Downloads a file to the user desktop. This works by creating a new
700 * window or an inner frame which downloads the file from the server.
701 * Due to `Content-Disposition` headers being set on the server, the
702 * web browser will popup a dialog for the user to save the file.
703 * This function can also be used for saving a file that doesn't
704 * exist by first posting the file (text) content to the server.
705 *
706 * @param {string} url the URL or filename to download
707 * @param {string} [data] the optional file data (if not available
708 *            on the server-side)
709 */
710RapidContext.App.downloadFile = function (url, data) {
711    if (data == null) {
712        url = url + (url.includes("?") ? "&" : "?") + "download";
713        var attrs = {
714            src: url,
715            border: "0",
716            frameborder: "0",
717            height: "0",
718            width: "0"
719        };
720        var iframe = MochiKit.DOM.createDOM("iframe", attrs);
721        document.body.append(iframe);
722    } else {
723        var name = MochiKit.DOM.INPUT({ name: "fileName", value: url });
724        var file = MochiKit.DOM.INPUT({ name: "fileData", value: data });
725        var flag = MochiKit.DOM.INPUT({ name: "download", value: "1" });
726        var formAttrs = {
727            action: "rapidcontext/download",
728            method: "POST",
729            target: "_blank",
730            style: { display: "none" }
731        };
732        var form = MochiKit.DOM.FORM(formAttrs, name, file, flag);
733        document.body.append(form);
734        form.submit();
735    }
736};
737
738/**
739 * Uploads a file to the server. The upload is handled by an
740 * asynchronous HTTP request.
741 *
742 * @param {string} id the upload identifier to use
743 * @param {File} file the file object to upload
744 * @param {function} [onProgress] the progress event handler
745 */
746RapidContext.App.uploadFile = function (id, file, onProgress) {
747    var formData = new FormData();
748    formData.append("file", file);
749    var opts = {
750        method: "POST",
751        body: formData,
752        timeout: 300000,
753        progress: onProgress,
754        log: id + " upload"
755    };
756    return RapidContext.Async.xhr("rapidcontext/upload/" + id, opts);
757};
758
759/**
760 * The application data cache. Contains the most recently retrieved
761 * data for some commonly used objects in the execution environment.
762 */
763RapidContext.App._Cache = {
764    version: new Date().getTime().toString(16).slice(-8),
765    status: null,
766    user: null,
767    apps: {},
768
769    // Normalizes an app manifest and its resources
770    _normalizeApp: function (app) {
771        function toType(type, url) {
772            var isJs = !type && /\.js$/i.test(url);
773            var isCss = !type && /\.css$/i.test(url);
774            if (["code", "js", "javascript"].includes(type) || isJs) {
775                return "code";
776            } else if (!type && /\.mjs$/i.test(url)) {
777                return "module";
778            } else if (["style", "css"].includes(type) || isCss) {
779                return "style";
780            } else if (!type && /\.json$/i.test(url)) {
781                return "json";
782            } else if (!type && /ui\.xml$/i.test(url)) {
783                return "ui";
784            } else if (!type && !app.icon && /\.(gif|jpg|jpeg|png|svg)$/i.test(url)) {
785                return "icon";
786            } else {
787                return type;
788            }
789        }
790        function toIcon(res) {
791            if (res.url) {
792                return $("<img/>").attr({ src: res.url }).addClass("-app-icon").get(0);
793            } else if (res.html) {
794                var node = $("<span/>").html(res.html).addClass("-app-icon").get(0);
795                return node.childNodes.length === 1 ? node.childNodes[0] : node;
796            } else if (res["class"]) {
797                return $("<i/>").addClass(res["class"]).addClass("-app-icon").get(0);
798            } else {
799                return null;
800            }
801        }
802        function toResource(res) {
803            res = (typeof(res) === "string") ? { url: res } : res;
804            res.type = toType(res.type, res.url);
805            if (res.url && !res.url.includes(":") && !res.url.startsWith("/")) {
806                var ver = app._version || RapidContext.App._Cache.version;
807                res.url = "rapidcontext/files@" + ver + "/" + res.url;
808            }
809            if (!app.icon && res.type === "icon") {
810                app.icon = toIcon(res);
811            }
812            return res;
813        }
814        app = Object.assign({}, app);
815        app.launch = app.launch || "manual";
816        app.resources = [].concat(app.resources).filter(Boolean).map(toResource);
817        if (!app.icon) {
818            app.icon = toIcon({ "class": "fa fa-4x fa-question-circle unimportant" });
819        }
820        return app;
821    },
822
823    // Updates the cache data with the results from a procedure.
824    update: function (proc, data) {
825        switch (proc) {
826        case "system/status":
827            this.status = Object.assign({}, data);
828            console.log("Updated cached status", this.status);
829            break;
830        case "system/session/current":
831            if (this.user && this.user.id !== (data && data.user && data.user.id)) {
832                RapidContext.UI.Msg.error.loggedOut();
833                RapidContext.App.callProc = () => Promise.reject(new Error("logged out"));
834            } else if (data && data.user && !this.user) {
835                // TODO: use deep clone
836                data = Object.assign({}, data.user);
837                data.longName = data.name ? `${data.name} (${data.id})` : data.id;
838                this.user = data;
839                console.log("Updated cached user", this.user);
840            }
841            break;
842        case "system/app/list":
843            for (var i = 0; data && i < data.length; i++) {
844                var launcher = this._normalizeApp(data[i]);
845                launcher.instances = (this.apps[launcher.id] || {}).instances || [];
846                this.apps[launcher.id] = Object.assign(this.apps[launcher.id] || {}, launcher);
847            }
848            console.log("Updated cached apps", this.apps);
849            break;
850        }
851    }
852};
853