Source RapidContext_App.js

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