Source RapidContext_App.js

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