diff --git a/gulp/preloader/preloader.css b/gulp/preloader/preloader.css index 4ff3e84b..3faba330 100644 --- a/gulp/preloader/preloader.css +++ b/gulp/preloader/preloader.css @@ -26,7 +26,7 @@ html { body { color: #555; user-select: none; - background: inherit !important; + background: inherit; overflow-wrap: break-word; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; diff --git a/src/css/error_handler.scss b/src/css/error_handler.scss new file mode 100644 index 00000000..19a57b1c --- /dev/null +++ b/src/css/error_handler.scss @@ -0,0 +1,89 @@ +:root { + // Provide a fallback font-size for UI scale calculations before the app loads + // TODO: perhaps use this as the primary source of UI scale? + font-size: #{"round(clamp(1px, calc(min(100vw, 100vh) * 0.019), 100px), 0.01px)"}; +} + +#errorHandler { + --background-primary: hsl(0deg 40% 18%); + --background-container: hsl(0deg 40% 10%); + --foreground: hsl(0deg 100% 95%); + + --background-button: rgba(255 255 255 / 0.05); + --background-button-hover: rgba(255 255 255 / 0.03); + --background-button-active: hsl(from var(--background-container) h s l / 0.4); + --background-button-success: hsl(130deg 40% 25%); + + background: var(--background-primary); + color: var(--foreground); + + font-size: 1.2rem; + line-height: 1.3; + + display: grid; + padding: 3rem; + gap: 0.8rem; + + grid-template-rows: auto 1fr 1fr auto; + grid-template-columns: auto minmax(20rem, 20%); + + pre { + background: var(--background-container); + border-radius: 0.3rem; + + font-size: 85%; + padding: 0.2rem 0.4rem; + overflow-y: auto; + white-space: pre-wrap; + overflow-wrap: break-word; + pointer-events: all; + } + + button { + background: var(--background-button); + border-radius: 0.3rem; + color: inherit; + transition: 0.15s ease-out background-color; + + padding: 0.6rem 0.6rem 0.8rem; + min-width: 10rem; + width: 15%; + font-size: inherit; + + &:hover { + background: var(--background-button-hover); + } + + &:active { + background: var(--background-button-active); + } + + &.success { + background: var(--background-button-success); + } + } + + .header, + .actions { + grid-column: 1 / 3; + } + + .stackTrace { + grid-row: 2 / 4; + } + + .loadedMods, + .buildInformation { + display: grid; + grid-template-rows: auto 1fr; + gap: 0.2rem; + overflow: auto; + } + + .actions { + display: flex; + padding-top: 1rem; + gap: 1rem; + justify-content: end; + } +} diff --git a/src/css/main.scss b/src/css/main.scss index 9592666c..74ad265c 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -13,6 +13,7 @@ @import "common"; @import "game_state"; @import "textual_game_state"; +@import "error_handler"; @import "states/preload"; @import "states/main_menu"; diff --git a/src/js/application.js b/src/js/application.js index 1204ad0a..98f3718c 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -1,5 +1,6 @@ import { AnimationFrame } from "./core/animation_frame"; import { BackgroundResourcesLoader } from "./core/background_resources_loader"; +import { ErrorHandler } from "./core/error_handler"; import { GameState } from "./core/game_state"; import { setGlobalApp } from "./core/globals"; import { InputDistributor } from "./core/input_distributor"; @@ -41,6 +42,8 @@ export class Application { async boot() { console.log("Booting ..."); + this.errorHandler = new ErrorHandler(); + logger.log("Creating application, platform =", getPlatformName()); setGlobalApp(this); diff --git a/src/js/core/error_handler.tsx b/src/js/core/error_handler.tsx new file mode 100644 index 00000000..e2e3bd38 --- /dev/null +++ b/src/js/core/error_handler.tsx @@ -0,0 +1,160 @@ +import { MODS } from "@/mods/modloader"; +import { T } from "@/translations"; +import copy from "clipboard-copy"; +import { BUILD_OPTIONS } from "./globals"; +import { removeAllChildren } from "./utils"; + +export class ErrorHandler { + isActive = true; + + constructor() { + window.addEventListener("error", this.onError.bind(this)); + window.addEventListener("unhandledrejection", this.onUnhandledRejection.bind(this)); + } + + private onError(ev: ErrorEvent) { + if (!this.isActive) { + return; + } + + // Don't trigger more than once + this.isActive = false; + + const screen = new ErrorScreen(ev.error, ev.filename, ev.lineno, ev.colno); + screen.show(); + } + + private onUnhandledRejection(ev: PromiseRejectionEvent) { + const error = ev.reason instanceof Error ? ev.reason : new Error(ev.reason); + + // Avoid logging the error twice + ev.preventDefault(); + + // Turn the unhandled rejection into a regular error event + throw new Error("Unhandled Promise rejection", { cause: error }); + } +} + +export class ErrorScreen { + private error: Error; + private file?: string; + private line?: number; + private column?: number; + + constructor(error: Error, file?: string, line?: number, column?: number) { + this.error = error; + this.file = file; + this.line = line; + this.column = column; + } + + show() { + // Set the global to stop future callback handlers + window.APP_ERROR_OCCURED = true; + + removeAllChildren(document.body); + document.body.id = "errorHandler"; + document.body.className = ""; + + const layout = this.createLayout(); + if (Array.isArray(layout)) { + document.body.append(...layout); + } else { + document.body.append(layout); + } + } + + private createLayout(): HTMLElement | HTMLElement[] { + const btnCopy = ; + const btnRestart = ; + + btnCopy.addEventListener("click", this.copyErrorLog.bind(this)); + btnRestart.addEventListener("click", this.restart.bind(this)); + + return ( + <> +
{this.recursiveStack}
+ {this.loadedMods}
+ {this.buildInformation}
+