diff --git a/.gitignore b/.gitignore index 8dce8df8..1d400eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ node_modules/ # Some build output /dist/ +/dist-package/ /declarations/ /travis-build/ diff --git a/.travis.yml b/.travis.yml index 1fa77b14..33cee1dd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,12 @@ -dist: trusty +dist: bionic language: node_js node_js: - "12" before_install: + - sudo apt-get update + - sudo apt-get -y install coreutils - chmod +x ./scripts/travis/build.sh - chmod +x ./scripts/travis/deploy_server.sh - chmod +x ./scripts/travis/deploy_github.sh @@ -29,11 +31,11 @@ deploy: # - provider: script # cleanup: false # skip_cleanup: true -# script: "bash scripts/travis/deploy_docker.sh development" +# script: "bash scripts/travis/deploy_docker.sh development" # on: # branch: develop - provider: script - script: "bash scripts/travis/deploy_server.sh production" + script: "bash scripts/travis/deploy_server.sh production" cleanup: false skip_cleanup: true on: @@ -41,7 +43,7 @@ deploy: - provider: script cleanup: false skip_cleanup: true - script: "bash scripts/travis/deploy_github.sh" + script: "bash scripts/travis/deploy_github.sh" on: branch: master # - provider: script diff --git a/ChangeLog.md b/ChangeLog.md index 7165537d..242bdba3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,83 @@ # Changelog: +* **26.05.21** + - Fixed automated builds + - Fixed the bookmark UI popout window + - Added a context menu for the general bookmark container + +* **05.05.21** + - Reworked the icon modal + - Fixed some minor icon and avatar related issues + - Improved icon modal performance + +* **29.04.21** + - Fixed a bug which caused chat messages to appear twice + - Adding support for poping out channel conversations + +* **27.04.21** + - Implemented support for showing the video feed watchers + - Updating the channel tree if the channel client order changes + +* **24.04.21** + - Removed the old server info modal and using the new React based and popoutable modal + - Using the new React modal for the server info dialog. The modal now also has improved permission and error visualisation + - Improved tooltip handling + +* **19.04.21** + - Fixed a bug that the client video box is shown as active even though the client does not stream any video + - Fixed a bug that the video fullscreen windows pops open when a client leaves/joins the channel + - Removed extra new line after blockquote for the markdown renderer + +* **05.04.21** + - Fixed the mute but for the webclient + - Fixed that "always active" microphone filter now works reliably + - Improved the recorder API + +* **29.03.21** + - Acquiring the default input recorder when opening the settings + - Adding new modal Input Processing Properties for the native client + - Fixed that you can't finish off the name editing by pressing enter + +* **25.03.21** + - Allowing to directly select the speaker output device + - Saving the speaker output device + +* **24.03.21** + - Improved the avatar upload modal (now way more intuitive) + - Fixed a bug which cause client avatars to be stuck within the loading screen + - Don't spam permission errors if we don't have the permission to view the channel description + - Showing channel group inheritance within the client info frame + +* **23.03.21** + - Made the permission editor popoutable + - Now using SVG flags for higher quality. + - Fixed issue [#74](https://github.com/TeaSpeak/TeaWeb/issues/74) (Swiss flag box has black background) + - Fixed issue that middle clicking on the channel does not shows the channel file browser instead it shows the global one + +* **21.03.21** + - Reworked the server group assignment modal. It now better reacts to the user input as well is now popoutable + +* **18.03.21** + - Finally, got fully rid of the initial backend glue and changes all systems to provider + +* **17.03.21** + - Updated from webpack 4 to webpack 5 + - Reworked the client build process + - Using webpack dev server from now on + +* **14.03.21** + - Enchanted the bookmark system + - Added support for auto connect on startup + - Cleaned and simplified up the bookmark UI + - Added support for importing/exporting bookmarks + - Added support for duplicating bookmarks + - Adding support for default channels and passwords + +* **12.03.21** + - Added a new video spotlight mode which allows showing multiple videos at the same time as well as + dragging and resizing them + - Fixed a minor bug within the permission editor + - Fixed the creation of channel groups + * **20.02.21** - Improved the browser IPC module - Added support for client invite links diff --git a/babel.config.ts b/babel.config.ts index e6e6d3bc..952ab4b6 100644 --- a/babel.config.ts +++ b/babel.config.ts @@ -1,10 +1,10 @@ -export = api => { +export default api => { api.cache(false); const presets = [ [ "@babel/preset-env", { - "corejs": { "version":3 }, + "corejs": {"version": 3}, "useBuiltIns": "usage", "targets": { "edge": "17", @@ -16,10 +16,12 @@ export = api => { } ] ]; + const plugins = [ ["@babel/transform-runtime"], ["@babel/plugin-transform-modules-commonjs"] ]; + return { presets, plugins diff --git a/client/app/index.scss b/client/app/AppMain.scss similarity index 100% rename from client/app/index.scss rename to client/app/AppMain.scss diff --git a/client/app/entry-points/AppMain.ts b/client/app/entry-points/AppMain.ts new file mode 100644 index 00000000..b248c848 --- /dev/null +++ b/client/app/entry-points/AppMain.ts @@ -0,0 +1,4 @@ +window.__native_client_init_shared(__webpack_require__); + +import "../AppMain.scss"; +import "tc-shared/entry-points/MainApp"; \ No newline at end of file diff --git a/client/app/index.ts b/client/app/entry-points/ModalWindow.ts similarity index 54% rename from client/app/index.ts rename to client/app/entry-points/ModalWindow.ts index 091fd57e..0c08ce71 100644 --- a/client/app/index.ts +++ b/client/app/entry-points/ModalWindow.ts @@ -1,4 +1,2 @@ window.__native_client_init_shared(__webpack_require__); - -import "./index.scss"; -import "tc-shared/main"; \ No newline at end of file +import "tc-shared/entry-points/ModalWindow"; \ No newline at end of file diff --git a/documentation/file-structure.md b/documentation/file-structure.md deleted file mode 100644 index aeecbcaf..00000000 --- a/documentation/file-structure.md +++ /dev/null @@ -1,42 +0,0 @@ -# File structure -The TeaSpeak web client is separated into 2 different parts. - -## I) Application files -Application files are all files which directly belong to the app itself. -Like the javascript files who handle the UI stuff or even translation templates. -Theses files are separated into two type of files. -1. [Shared application files](#1-shared-application-files) -2. [Web application files](#2-web-application-files) - -### 1. Shared application files -Containing all files used by the TeaSpeak client and the Web client. -All of these files will be found within the folder `shared`. -This folder follows the general application file structure. -More information could be found [here](#application-file-structure) - -### 2. Web application files -All files which only belong to a browser only instance. -All of these files will be found within the folder `web`. -This folder follows the general application file structure. -More information could be found [here](#application-file-structure) - -### application file structure -Every application root contains several subfolders. -In the following list will be listed which files belong to which folder - -| Folder | Description | -| --- | --- | -| `audio` | This folder contains all audio files used by the application. More information could be found [here](). | -| `css` | This folder contains all style sheets used by the application. More information could be found [here](). | -| `js` | This folder contains all javascript files used by the application. More information could be found [here](). | -| `html` | This folder contains all HTML and PHP files used by the application. More information could be found [here](). | -| `i18n` | This folder contains all default translations. Information about the translation system could be found [here](). | -| `img` | This folder contains all image files. | - -## I) Additional tools - -## Environment builder -The environment builder is one of the most important tools of the entire project. -This tool, basically implemented in the file `files.php`, will be your helper while live developing. -What this tool does is, it creates a final environment where you could navigate to with your browser. -It merges all the type separated files, which had been listed above ([here](#application-file-structure)). \ No newline at end of file diff --git a/file.ts b/file.ts index a2867ad6..cecb9766 100644 --- a/file.ts +++ b/file.ts @@ -30,49 +30,26 @@ type ProjectResource = { } const APP_FILE_LIST_SHARED_SOURCE: ProjectResource[] = [ + { /* javascript files as manifest.json */ + "type": "js", + "search-pattern": /.*\.(js|json|svg|png|css)$/, + "build-target": "dev|rel", + + "path": "js/", + "local-path": "./dist/" + }, + { /* shared html files */ "type": "html", "search-pattern": /^.*([a-zA-Z]+)\.(html|json)$/, "build-target": "dev|rel", "path": "./", - "local-path": "./shared/html/" - }, - { /* javascript files as manifest.json */ - "type": "js", - "search-pattern": /.*\.(js|json|svg)$/, - "build-target": "dev|rel", - - "path": "js/", "local-path": "./dist/" }, - { /* javascript files as manifest.json */ - "type": "html", - "search-pattern": /.*\.html$/, - "build-target": "dev|rel", - - "path": "./", - "local-path": "./dist/" - }, - { /* Loader css file (only required in dev mode. In release it gets inlined) */ - "type": "css", - "search-pattern": /.*\.css$/, - "build-target": "dev", - - "path": "css/", - "local-path": "./loader/css/" - }, { /* shared sound files */ "type": "wav", - "search-pattern": /.*\.wav$/, - "build-target": "dev|rel", - - "path": "audio/", - "local-path": "./shared/audio/" - }, - { /* shared data sound files */ - "type": "json", - "search-pattern": /.*\.json/, + "search-pattern": /.*\.(wav|json)$/, "build-target": "dev|rel", "path": "audio/", @@ -87,15 +64,6 @@ const APP_FILE_LIST_SHARED_SOURCE: ProjectResource[] = [ "path": "img/", "local-path": "./shared/img/" }, - { /* assembly files */ - "web-only": true, - "type": "wasm", - "search-pattern": /.*\.(wasm)/, - "build-target": "dev|rel", - - "path": "js/", - "local-path": "./dist/" - } ]; const APP_FILE_LIST_SHARED_VENDORS: ProjectResource[] = []; @@ -297,7 +265,7 @@ namespace server { } else { server = http.createServer(handleHTTPRequest); } - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { server.on('error', reject); server.listen(options.port, () => { server.off("error", reject); @@ -308,7 +276,7 @@ namespace server { export async function shutdown() { if(server) { - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); server = undefined; } } @@ -428,7 +396,7 @@ namespace watcher { this._process.addListener("error", this.handle_error.bind(this)); try { - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const id = setTimeout(reject, 5000, "timeout"); this._callback_init = () => { clearTimeout(id); diff --git a/loader/IndexGenerator.ts b/loader/IndexGenerator.ts deleted file mode 100644 index 6b049ca8..00000000 --- a/loader/IndexGenerator.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as path from "path"; -import EJSGenerator = require("../webpack/EJSGenerator"); - -class IndexGenerator extends EJSGenerator { - constructor(options: { - buildTarget: string; - output: string, - isDevelopment: boolean - }) { - super({ - variables: { - build_target: options.buildTarget - }, - output: options.output, - initialJSEntryChunk: "loader", - input: path.join(__dirname, "html/index.html.ejs"), - minify: !options.isDevelopment, - - embedInitialJSEntryChunk: !options.isDevelopment, - embedInitialCSSFile: !options.isDevelopment, - - initialCSSFile: { - localFile: path.join(__dirname, "css/index.css"), - publicFile: "css/index.css" - } - }); - } - -} - -export = IndexGenerator; \ No newline at end of file diff --git a/loader/app/animation.ts b/loader/app/animation.ts index 36c5ade9..0d4ba87d 100644 --- a/loader/app/animation.ts +++ b/loader/app/animation.ts @@ -1,6 +1,6 @@ import * as loader from "./loader/loader"; import {Stage} from "./loader/loader"; -import {getUrlParameter} from "./loader/utils"; +import {getUrlParameter} from "./loader/Utils"; let overlay: HTMLDivElement; let setupContainer: HTMLDivElement; diff --git a/loader/app/bootstrap.ts b/loader/app/bootstrap.ts new file mode 100644 index 00000000..9c169a29 --- /dev/null +++ b/loader/app/bootstrap.ts @@ -0,0 +1,99 @@ +import "core-js/stable"; +import "./polifill"; +import "./css"; + +import {ApplicationLoader} from "./loader/loader"; +import {getUrlParameter} from "./loader/Utils"; + +/* let the loader register himself at the window first */ +const target = getUrlParameter("loader-target") || "app"; +console.info("Loading app with loader \"%s\"", target); + +let appLoader: ApplicationLoader; +if(target === "empty") { + appLoader = new (require("./targets/empty").default); +} else if(target === "manifest") { + appLoader = new (require("./targets/maifest-target").default); +} else { + appLoader = new (require("./targets/app").default); +} +setTimeout(() => appLoader.execute(), 0); + +export {}; + +if(__build.target === "client") { + /* do this so we don't get a react dev tools warning within the client */ + if(!('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window)) { + window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {}; + } + + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {}; +} + +/* Hello World message */ +{ + const clog = console.log; + const print_security = () => { + { + const css = [ + "display: block", + "text-align: center", + "font-size: 42px", + "font-weight: bold", + "-webkit-text-stroke: 2px black", + "color: red" + ].join(";"); + clog("%c ", "font-size: 100px;"); + clog("%cSecurity warning:", css); + } + { + const css = [ + "display: block", + "text-align: center", + "font-size: 18px", + "font-weight: bold" + ].join(";"); + + clog("%cPasting anything in here could give attackers access to your data.", css); + clog("%cUnless you understand exactly what you are doing, close this window and stay safe.", css); + clog("%c ", "font-size: 100px;"); + } + }; + + /* print the hello world */ + { + const css = [ + "display: block", + "text-align: center", + "font-size: 72px", + "font-weight: bold", + "-webkit-text-stroke: 2px black", + "color: #18BC9C" + ].join(";"); + clog("%cHey, hold on!", css); + } + { + const css = [ + "display: block", + "text-align: center", + "font-size: 26px", + "font-weight: bold" + ].join(";"); + + const css_2 = [ + "display: block", + "text-align: center", + "font-size: 26px", + "font-weight: bold", + "color: blue" + ].join(";"); + + const display_detect = /./; + display_detect.toString = function() { print_security(); return ""; }; + + clog("%cLovely to see you using and debugging the TeaSpeak-Web client.", css); + clog("%cIf you have some good ideas or already done some incredible changes,", css); + clog("%cyou'll be may interested to share them here: %chttps://github.com/TeaSpeak/TeaWeb", css, css_2); + clog("%c ", display_detect); + } +} \ No newline at end of file diff --git a/loader/css/index.scss b/loader/app/css/index.scss similarity index 77% rename from loader/css/index.scss rename to loader/app/css/index.scss index d95da3c1..54278fcc 100644 --- a/loader/css/index.scss +++ b/loader/app/css/index.scss @@ -8,7 +8,4 @@ body { *, :before, :after { box-sizing: border-box; outline: none; -} - -@import "loader"; -@import "overlay"; +} \ No newline at end of file diff --git a/loader/app/css/index.ts b/loader/app/css/index.ts new file mode 100644 index 00000000..36ac23e4 --- /dev/null +++ b/loader/app/css/index.ts @@ -0,0 +1,3 @@ +import "./index.scss"; +import "./loader.scss"; +import "./overlay.scss"; \ No newline at end of file diff --git a/loader/app/css/loader.scss b/loader/app/css/loader.scss new file mode 100644 index 00000000..82a8c2d1 --- /dev/null +++ b/loader/app/css/loader.scss @@ -0,0 +1,223 @@ +:global { + $setup-time-normal: 80s / 24; /* 24 frames / sec; the initial sequence is 80 frames */ + $setup-time-halloween: 323s / 24; + $loop-time-halloween: 25s / 24; + + #loader-overlay { + position: absolute; + overflow: hidden; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + background: #1e1e1e; + + user-select: none; + + z-index: 10000000; + + display: flex; + flex-direction: column; + justify-content: center; + + -webkit-app-region: drag; + + .container { + flex-shrink: 0; + + display: block; + position: relative; + + width: 1000px; + height: 1000px; + + align-self: center; + margin-bottom: 10vh; + + transition-duration: .5s; + + img { + user-select: none; + } + } + + .setup, .idle { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: none; + + &.visible { + display: block; + } + } + + .setup.visible { + &.normal { + animation: loader-initial-sequence 0s cubic-bezier(.81,.01,.65,1.16) $setup-time-normal forwards; + } + + &.halloween { + animation: loader-initial-sequence 0s cubic-bezier(.81,.01,.65,1.16) $setup-time-halloween forwards; + } + } + + .idle.animation-normal { + img { + position: absolute; + } + + .steam { + position: absolute; + + top: 282px; + left: 380px; + + width: 249px; + height: 125px; + background: url("../../images/steam.png") 0 0; + + animation: sprite-steam 2.5s steps(50) forwards infinite; + } + } + + &.finishing { + .idle { + .steam { + display: none; + } + + .bowl { + animation: swipe-out-bowl .5s both; + } + + .text { + animation: swipe-out-text .5s .12s both; + } + } + + pointer-events: none; + animation: overlay-fade .3s .2s both; + } + + .loader-stage { + position: absolute; + + left: 5px; + bottom: 5px; + + font-size: 12px; + font-family: monospace; + + color: #999; + } + } + + /* Automated loader timeout */ + #loader-overlay:not(.initialized) + #critical-load:not(.shown) { + display: block !important; + opacity: 0; + + animation: loader-setup-timeout 0s ease-in $setup-time-normal forwards; + + .error::before { + content: 'Failed to startup loader!'; + } + + .detail::before { + content: 'Lookup the console for more details'; + } + } +} + +@media all and (max-width: 850px) { + :global { + #loader-overlay .container { + transform: scale(.5); + } + } +} + +@media all and (max-height: 700px) { + :global { + #loader-overlay .container { + transform: scale(.5); + } + } +} + +@media all and (max-width: 400px) { + :global { + #loader-overlay .container { + transform: scale(.3); + } + } +} + +@keyframes :global(loader-initial-sequence) { + to { + display: none; + } +} + +@keyframes :global(sprite-steam) { + to { + background-position: 0 -6250px; + } +} + +@keyframes :global(swipe-out-bowl) { + from { + transform: translate3d(0, 0, 0); + } + + 40% { + opacity: 1; + transform: translate3d(-60px, 0, 0) skew(-5deg, 0) rotateY(-6deg); + } + + to { + opacity: 0; + transform: translate3d(700px, 0, 0) skew(30deg, 0) rotateZ(-6deg); + } +} + +@keyframes :global(swipe-out-text) { + from { + transform: translate3d(0, 0, 0); + } + + 40% { + opacity: 1; + transform: translate3d(-30px, 20px, 0) skew(-5deg, 0); + } + + to { + opacity: 0; + transform: translate3d(550px, 0, 0) skew(30deg, 0) scale(.96, 1.25) rotateZ(6deg); + } +} + +@keyframes :global(animation-nothing) { + to { + background-position: 0 -6250px; + } +} + +@keyframes :global(overlay-fade) { + to { + opacity: 0; + } +} + +@keyframes :global(loader-setup-timeout) { + to { + opacity: 1; + } +} \ No newline at end of file diff --git a/loader/app/css/overlay.scss b/loader/app/css/overlay.scss new file mode 100644 index 00000000..bcc8c3dc --- /dev/null +++ b/loader/app/css/overlay.scss @@ -0,0 +1,82 @@ +:global { + #overlay-no-js, #critical-load { + z-index: 100000000; + display: none; + position: fixed; + + top: 0; + bottom: 0; + left: 0; + right: 0; + + background: #1e1e1e; + text-align: center; + + -webkit-app-region: drag; + + h1, h3, a { + -webkit-app-region: no-drag; + } + + .container { + position: relative; + display: inline-block; + + top: 20%; + } + + &.shown { + display: block; + } + } + + #overlay-no-js { + display: block; + color: #999; + + svg { + fill: #999; + } + } + + #critical-load { + .img { + height: 12em + } + + .error { + color: #bd1515; + margin-bottom: 0 + } + + .detail { + color: #696363; + margin-top: .5em + } + } + + svg { + max-height: 100%; + max-width: 100%; + } +} + + +@media (max-height: 750px) { + :global { + #critical-load .container { + top: unset; + } + + #critical-load { + font-size: .8rem; + + flex-direction: column; + justify-content: center; + } + + #critical-load.shown { + display: flex; + } + } +} \ No newline at end of file diff --git a/loader/app/index.ts b/loader/app/index.ts index f976a9e4..836505a8 100644 --- a/loader/app/index.ts +++ b/loader/app/index.ts @@ -1,31 +1,14 @@ -import "core-js/stable"; -import "./polifill"; - -import * as loader from "./loader/loader"; -import {ApplicationLoader} from "./loader/loader"; -import {getUrlParameter} from "./loader/utils"; - -window["loader"] = loader; -/* let the loader register himself at the window first */ - -const target = getUrlParameter("loader-target") || "app"; -console.info("Loading app with loader \"%s\"", target); - -let appLoader: ApplicationLoader; -if(target === "empty") { - appLoader = new (require("./targets/empty").default); -} else if(target === "manifest") { - appLoader = new (require("./targets/maifest-target").default); -} else { - appLoader = new (require("./targets/app").default); +if(window["loader"]) { + throw "an loader instance has already been defined"; } -setTimeout(() => appLoader.execute(), 0); -export {}; +export * from "./loader/loader"; +export * as loaderAnimation from "./animation"; -if(__build.target === "client") { - /* do this so we don't get a react dev tools warning within the client */ - if(!('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window)) - window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {}; - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = function () {}; -} \ No newline at end of file +import "./bootstrap"; + +/* FIXME: This is glue! */ +if(window["loader"]) { + throw "an loader instance has already been defined"; +} +window["loader"] = module.exports; \ No newline at end of file diff --git a/loader/app/loader/Performance.ts b/loader/app/loader/Performance.ts new file mode 100644 index 00000000..469cfd29 --- /dev/null +++ b/loader/app/loader/Performance.ts @@ -0,0 +1,128 @@ +export type ResourceRequestResult = { + status: "success" +} | { + status: "unknown-error", + message: string +} | { + status: "error-event" +} | { + status: "timeout", + givenTimeout: number +}; + +export type ResourceType = "script" | "css" | "json"; + +export class ResourceRequest { + private readonly type: ResourceType; + private readonly name: string; + + private status: "unset" | "pending" | "executing" | "executed"; + private result: ResourceRequestResult | undefined; + + private timestampEnqueue: number; + private timestampExecuting: number; + private timestampExecuted: number; + + constructor(type: ResourceType, name: string) { + this.type = type; + this.name = name; + + this.status = "unset"; + } + + markEnqueue() { + if(this.status !== "unset") { + console.warn("ResourceRequest %s status isn't unset.", this.name); + return; + } + + this.timestampEnqueue = performance.now(); + this.status = "pending"; + } + + markExecuting() { + switch (this.status) { + case "unset": + /* the markEnqueue() invoke has been skipped */ + break; + + case "pending": + break; + + default: + console.warn("ResourceRequest %s has invalid status to call markExecuting.", this.name); + return; + } + + this.timestampExecuting = performance.now(); + this.status = "executing"; + } + + markExecuted(result: ResourceRequestResult) { + switch (this.status) { + case "unset": + /* the markEnqueue() invoke has been skipped */ + break; + + case "pending": + /* the markExecuting() invoke has been skipped */ + break; + + case "executing": + break; + + default: + console.warn("ResourceRequest %s has invalid status to call markExecuted.", this.name); + return; + } + + this.result = result; + this.timestampExecuted = performance.now(); + this.status = "executed"; + } + + generateReportString() { + let timeEnqueued, timeExecuted; + if(this.timestampEnqueue === 0) { + timeEnqueued = "unknown"; + } else { + let endTimestamp = Math.min(this.timestampExecuting, this.timestampExecuted); + if (endTimestamp === 0) { + timeEnqueued = "pending"; + } else { + timeEnqueued = endTimestamp - this.timestampEnqueue; + } + } + + if(this.timestampExecuted === 0) { + timeExecuted = "unknown"; + } else { + if( this.timestampExecuted === 0) { + timeExecuted = "pending"; + } else { + timeExecuted = this.timestampExecuted - this.timestampEnqueue; + } + } + + return `ResourceRequest{ type: ${this.type}, time enqueued: ${timeEnqueued}, time executed: ${timeExecuted}, name: ${this.name} }`; + } +} + +export class LoaderPerformanceLogger { + private readonly resourceTimings: ResourceRequest[] = []; + private eventTimeBase: number; + + constructor() { + this.eventTimeBase = performance.now(); + } + + getResourceTimings() : ResourceRequest[] { + return this.resourceTimings; + } + + logResourceRequest(type: ResourceType, name: string) : ResourceRequest { + const request = new ResourceRequest(type, name); + this.resourceTimings.push(request); + return request; + } +} \ No newline at end of file diff --git a/loader/app/loader/ScriptLoader.ts b/loader/app/loader/ScriptLoader.ts new file mode 100644 index 00000000..6f336c5e --- /dev/null +++ b/loader/app/loader/ScriptLoader.ts @@ -0,0 +1,92 @@ +import {config, critical_error, loaderPerformance, SourcePath} from "./loader"; +import {executeParallelLoad, LoadCallback, LoadSyntaxError, ParallelOptions} from "./Utils"; + +export function loadScript(url: SourcePath) : Promise { + const givenTimeout = 120 * 1000; + + const resourceRequest = loaderPerformance.logResourceRequest("script", url); + resourceRequest.markEnqueue(); + + return new Promise((resolve, reject) => { + const scriptTag = document.createElement("script"); + scriptTag.type = "application/javascript"; + scriptTag.async = true; + scriptTag.defer = true; + + const cleanup = () => { + scriptTag.onerror = undefined; + scriptTag.onload = undefined; + + clearTimeout(timeoutHandle); + }; + + const timeoutHandle = setTimeout(() => { + resourceRequest.markExecuted({ status: "timeout", givenTimeout: givenTimeout }); + cleanup(); + reject("timeout"); + }, givenTimeout); + + /* TODO: Test if on syntax error the parameters contain extra info */ + scriptTag.onerror = () => { + resourceRequest.markExecuted({ status: "error-event" }); + scriptTag.remove(); + cleanup(); + reject(); + }; + + scriptTag.onload = () => { + resourceRequest.markExecuted({ status: "success" }); + cleanup(); + resolve(); + }; + + scriptTag.onloadstart = () => { + } + + scriptTag.src = config.baseUrl + url; + document.body.appendChild(scriptTag); + resourceRequest.markExecuting(); + }); +} + +type MultipleOptions = ParallelOptions; +export async function loadScripts(paths: SourcePath[], options: MultipleOptions, callback?: LoadCallback) : Promise { + const result = await executeParallelLoad(paths, e => loadScript(e), e => e, options, callback); + if(result.failed.length > 0) { + if(config.error) { + console.error("Failed to load the following scripts:"); + for(const script of result.failed) { + const sname = script.request; + if(script.error instanceof LoadSyntaxError) { + const source = script.error.source as Error; + if(source.name === "TypeError") { + let prefix = ""; + while(prefix.length < sname.length + 7) prefix += " "; + console.log(" - %s: %s:\n%s", sname, source.message, source.stack.split("\n").map(e => prefix + e.trim()).slice(1).join("\n")); + } else if(typeof source === "string") { + console.log(" - %s: %s", sname, source); + } else { + console.log(" - %s: %o", sname, source); + } + } else { + console.log(" - %s: %o", sname, script.error); + } + } + } + + let errorMessage; + { + const error = result.failed[0].error; + if(error instanceof LoadSyntaxError) { + errorMessage = error.source.message; + } else if(typeof error === "string") { + errorMessage = error; + } else { + console.error("Script %s loading error: %o", result.failed[0].request, error); + errorMessage = "View the browser console for more information!"; + } + critical_error("Failed to load script " + result.failed[0].request, errorMessage); + } + throw "failed to load script " + result.failed[0].request + " (" + errorMessage + ")"; + } +} \ No newline at end of file diff --git a/loader/app/loader/StyleLoader.ts b/loader/app/loader/StyleLoader.ts new file mode 100644 index 00000000..39824e9c --- /dev/null +++ b/loader/app/loader/StyleLoader.ts @@ -0,0 +1,72 @@ +import {config, critical_error, loaderPerformance, SourcePath} from "./loader"; +import {executeParallelLoad, LoadCallback, LoadSyntaxError, ParallelOptions} from "./Utils"; + +export function loadStyle(path: SourcePath) : Promise { + const givenTimeout = 120 * 1000; + + const resourceRequest = loaderPerformance.logResourceRequest("script", path); + resourceRequest.markEnqueue(); + + return new Promise((resolve, reject) => { + const linkTag = document.createElement("link"); + + linkTag.type = "text/css"; + linkTag.rel = "stylesheet"; + linkTag.href = config.baseUrl + path; + + const cleanup = () => { + linkTag.onerror = undefined; + linkTag.onload = undefined; + + clearTimeout(timeoutHandle); + }; + + const errorCleanup = () => { + linkTag.remove(); + cleanup(); + }; + + const timeoutHandle = setTimeout(() => { + resourceRequest.markExecuted({ status: "timeout", givenTimeout: givenTimeout }); + cleanup(); + reject("timeout"); + }, givenTimeout); + + /* TODO: Test if on syntax error the parameters contain extra info */ + linkTag.onerror = () => { + resourceRequest.markExecuted({ status: "error-event" }); + errorCleanup(); + reject(); + }; + + linkTag.onload = () => { + resourceRequest.markExecuted({ status: "success" }); + cleanup(); + resolve(); + }; + + document.head.appendChild(linkTag); + resourceRequest.markExecuting(); + }); +} + +export type MultipleOptions = ParallelOptions; +export async function loadStyles(paths: SourcePath[], options: MultipleOptions, callback?: LoadCallback) : Promise { + const result = await executeParallelLoad(paths, e => loadStyle(e), e => e, options, callback); + if(result.failed.length > 0) { + if(config.error) { + console.error("Failed to load the following style sheets:"); + for(const style of result.failed) { + const sname = style.request; + if(style.error instanceof LoadSyntaxError) { + console.log(" - %s: %o", sname, style.error.source); + } else { + console.log(" - %s: %o", sname, style.error); + } + } + } + + critical_error("Failed to load style " + result.failed[0].request + "
" + "View the browser console for more information!"); + throw "failed to load style " + result.failed[0].request; + } +} \ No newline at end of file diff --git a/loader/app/loader/utils.ts b/loader/app/loader/Utils.ts similarity index 62% rename from loader/app/loader/utils.ts rename to loader/app/loader/Utils.ts index 2c148950..c400f8cd 100644 --- a/loader/app/loader/utils.ts +++ b/loader/app/loader/Utils.ts @@ -1,10 +1,8 @@ -import {SourcePath} from "./loader"; -import {Options} from "./script_loader"; - export const getUrlParameter = key => { const match = location.search.match(new RegExp("(.*[?&]|^)" + key + "=([^&]+)($|&.*)")); - if(!match) + if(!match) { return undefined; + } return match[2]; }; @@ -16,24 +14,15 @@ export class LoadSyntaxError { } } -export function script_name(path: SourcePath, html: boolean) { - if(Array.isArray(path)) { - return path.filter(e => !!e).map(e => script_name(e, html)).join(" or "); - } else if(typeof(path) === "string") - return html ? "" + path + "" : path; - else - return html ? "" + path.url + "" : path.url; -} - -export interface ParallelOptions extends Options { - max_parallel_requests?: number +export interface ParallelOptions { + maxParallelRequests?: number } export interface ParallelResult { succeeded: T[]; failed: { request: T, - error: T + error: any }[], skipped: T[]; @@ -41,15 +30,22 @@ export interface ParallelResult { export type LoadCallback = (entry: T, state: "loading" | "loaded") => void; -export async function load_parallel(requests: T[], executor: (_: T) => Promise, stringify: (_: T) => string, options: ParallelOptions, callback?: LoadCallback) : Promise> { +export async function executeParallelLoad( + requests: T[], + executor: (_: T) => Promise, + stringify: (_: T) => string, + options: ParallelOptions, + callback?: LoadCallback +) : Promise> { const result: ParallelResult = { failed: [], succeeded: [], skipped: [] }; const pendingRequests = requests.slice(0).reverse(); /* we're only able to pop from the back */ const currentRequests = {}; - if(typeof callback === "undefined") + if(typeof callback === "undefined") { callback = () => {}; + } - const maxParallelRequests = typeof options.max_parallel_requests === "number" && options.max_parallel_requests > 0 ? options.max_parallel_requests : Number.MAX_SAFE_INTEGER; + const maxParallelRequests = typeof options.maxParallelRequests === "number" && options.maxParallelRequests > 0 ? options.maxParallelRequests : Number.MAX_SAFE_INTEGER; while (pendingRequests.length > 0) { while(Object.keys(currentRequests).length < maxParallelRequests) { const element = pendingRequests.pop(); @@ -60,8 +56,10 @@ export async function load_parallel(requests: T[], executor: (_: T) => Promis delete currentRequests[name]; callback(element, "loaded"); }); - if(pendingRequests.length == 0) + + if(pendingRequests.length == 0) { break; + } } /* @@ -69,8 +67,9 @@ export async function load_parallel(requests: T[], executor: (_: T) => Promis * This should also not throw because any errors will be caught before. */ await Promise.race(Object.keys(currentRequests).map(e => currentRequests[e])); - if(result.failed.length > 0) + if(result.failed.length > 0) { break; /* finish loading the other requests and than show the error */ + } } await Promise.all(Object.keys(currentRequests).map(e => currentRequests[e])); result.skipped.push(...pendingRequests); diff --git a/loader/app/loader/loader.ts b/loader/app/loader/loader.ts index 6b5bd5b4..535f2df1 100644 --- a/loader/app/loader/loader.ts +++ b/loader/app/loader/loader.ts @@ -1,7 +1,6 @@ -import * as script_loader from "./script_loader"; -import * as template_loader from "./template_loader"; import * as Animation from "../animation"; -import {getUrlParameter} from "./utils"; +import {getUrlParameter} from "./Utils"; +import {LoaderPerformanceLogger} from "./Performance"; export interface ApplicationLoader { execute(); @@ -76,30 +75,9 @@ export enum Stage { DONE } -let cache_tag: string | undefined; let currentStage: Stage = undefined; const tasks: {[key:number]: InternalTask[]} = {}; -/* test if all files shall be load from cache or fetch again */ -function loader_cache_tag() { - if(__build.mode === "debug") { - cache_tag = "?_ts=" + Date.now(); - return; - } - - const cached_version = localStorage.getItem("cached_version"); - if(!cached_version || cached_version !== __build.version) { - register_task(Stage.LOADED, { - priority: 0, - name: "cached version updater", - function: async () => { - localStorage.setItem("cached_version", __build.version); - } - }); - } - cache_tag = "?_version=" + __build.version; -} - export type ModuleMapping = { application: string, modules: { @@ -111,8 +89,6 @@ export type ModuleMapping = { const module_mapping_: ModuleMapping[] = []; export function module_mapping() : ModuleMapping[] { return module_mapping_; } -export function get_cache_version() { return cache_tag; } - export function finished() { return currentStage == Stage.DONE; } @@ -176,22 +152,28 @@ export function setCurrentTaskName(taskId: number, name: string) { } export async function execute(customLoadingAnimations: boolean) { - if(!await Animation.initialize(customLoadingAnimations)) + if(!await Animation.initialize(customLoadingAnimations)) { return; + } - loader_cache_tag(); + /* Cleanup