From 653184b8f8aabb5c17a1ce8149866f3801c3843e Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 24 Sep 2022 21:58:01 -0700 Subject: [PATCH] JavaScript Exception Catcher UI * Add an exception catcher that pops open a UI window showing errors that occur in doodad scripts during gameplay. * Shows a preview of the header of the error (character wrapped) with a Copy button to copy the full raw text to clipboard for inspection. * Buttons to dismiss the modal once or stop any further errors from opening during this gameplay session (until next restart). * Add developer shell commands to test the exception catcher: - 'throw ' to throw a custom message. - 'throw2' to stress test a "long" message. - 'throw3' to throw a realistic message copied from an actual error. * Scripting engine: console.log() and friends will now insert the script VM's name in front of its messages (the filename + actor ID). --- pkg/balance/theme.go | 8 + pkg/commands.go | 24 +++ pkg/doodle.go | 4 +- pkg/native/engine_sdl.go | 10 + pkg/native/engine_wasm.go | 5 + pkg/play_scene.go | 2 +- pkg/scene.go | 4 + pkg/scripting/events.go | 17 +- pkg/scripting/exceptions/exceptions.go | 262 +++++++++++++++++++++++++ pkg/scripting/js_api.go | 18 +- pkg/scripting/pubsub.go | 3 +- pkg/scripting/vm.go | 2 +- pkg/uix/canvas_actors.go | 10 +- 13 files changed, 356 insertions(+), 13 deletions(-) create mode 100644 pkg/scripting/exceptions/exceptions.go diff --git a/pkg/balance/theme.go b/pkg/balance/theme.go index aa0d2d7..e8a6f3e 100644 --- a/pkg/balance/theme.go +++ b/pkg/balance/theme.go @@ -174,6 +174,14 @@ var ( Color: render.Magenta, } + // ExceptionFont for showing JavaScript errors to the user. + ExceptionFont = render.Text{ + Size: 12, + PadX: 3, + FontFilename: MonospaceFont, + Color: render.Black, + } + // Small font SmallFont = render.Text{ Size: 10, diff --git a/pkg/commands.go b/pkg/commands.go index 630221f..4fa3731 100644 --- a/pkg/commands.go +++ b/pkg/commands.go @@ -14,6 +14,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/enum" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/modal" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "github.com/dop251/goja" ) @@ -88,6 +89,29 @@ func (c Command) Run(d *Doodle) error { case "extract-bindata": // Undocumented command to extract the binary of its assets. return c.ExtractBindata(d, c.ArgsLiteral) + case "throw": + // Test exception catcher with custom message. + exceptions.Catch(strings.ReplaceAll(c.ArgsLiteral, "\\n", "\n")) + return nil + case "throw2": + // Stress test exception catcher. + exceptions.Catch( + "This is a test of the Exception Catcher modal.\n\nIt should be able to display a decent amount " + + "of text with character wrapping. Multiple lines, too.\n\nIt might not show the full message, so " + + "click the 'Copy' button to copy to clipboard and read the whole message. There is more text " + + "than is shown on screen.\n\nThis text for example was not on screen, but copied to your " + + "clipboard anyway. :)", + ) + return nil + case "throw3": + // Realistic exception. + exceptions.Catch( + "Error in main() for actor trapdoor-down.doodad:\n\n" + + "TypeError: Cannot read property 'zz' of undefined at main (:25:14(77))\n\n" + + "Actor ID: c3aa346b-be51-4bc4-94bb-f3adf5643830\n" + + "Filename: trapdoor-down.doodad\n" + + "Position: 643,266", + ) default: return c.Default(d) } diff --git a/pkg/doodle.go b/pkg/doodle.go index 864d3ce..c794697 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -19,6 +19,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen" "git.kirsle.net/SketchyMaze/doodle/pkg/native" "git.kirsle.net/SketchyMaze/doodle/pkg/pattern" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "git.kirsle.net/SketchyMaze/doodle/pkg/shmem" "git.kirsle.net/SketchyMaze/doodle/pkg/usercfg" "git.kirsle.net/SketchyMaze/doodle/pkg/windows" @@ -170,7 +171,7 @@ func (d *Doodle) Run() error { // Make sure no UI modals (alerts, confirms) // or loadscreen are currently visible. - if !modal.Handled(ev) { + if !modal.Handled(ev) && !exceptions.Handled(ev) { // Global event handlers. if keybind.Shutdown(ev) { if d.Debug { // fast exit in -debug mode. @@ -197,6 +198,7 @@ func (d *Doodle) Run() error { loadscreen.Loop(render.NewRect(d.width, d.height), d.Engine) // Draw modals on top of the game UI. + exceptions.Draw(d.Engine) modal.Draw() // Draw the shell, always on top of UI and modals. diff --git a/pkg/native/engine_sdl.go b/pkg/native/engine_sdl.go index b38d747..eede110 100644 --- a/pkg/native/engine_sdl.go +++ b/pkg/native/engine_sdl.go @@ -4,8 +4,10 @@ package native import ( + "errors" "image" + "git.kirsle.net/SketchyMaze/doodle/pkg/shmem" "git.kirsle.net/go/render" "git.kirsle.net/go/render/sdl" sdl2 "github.com/veandco/go-sdl2/sdl" @@ -23,6 +25,14 @@ func HasTouchscreen(e render.Engine) bool { return false } +// CopyToClipboard puts some text on your clipboard. +func CopyToClipboard(text string) error { + if _, ok := shmem.CurrentRenderEngine.(*sdl.Renderer); ok { + return sdl2.SetClipboardText(text) + } + return errors.New("not supported") +} + /* TextToImage takes an SDL2_TTF texture and makes it into a Go image. diff --git a/pkg/native/engine_wasm.go b/pkg/native/engine_wasm.go index 5d156b5..8379a2d 100644 --- a/pkg/native/engine_wasm.go +++ b/pkg/native/engine_wasm.go @@ -1,3 +1,4 @@ +//go:build js && wasm // +build js,wasm package native @@ -15,3 +16,7 @@ func HasTouchscreen(e render.Engine) bool { func TextToImage(e render.Engine, text render.Text) (image.Image, error) { return nil, errors.New("not supported on WASM") } + +func CopyToClipboard(text string) error { + return errors.New("not supported on WASM") +} diff --git a/pkg/play_scene.go b/pkg/play_scene.go index afce9cf..1977a99 100644 --- a/pkg/play_scene.go +++ b/pkg/play_scene.go @@ -471,7 +471,7 @@ func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, cen spawn = s.SpawnPoint } - s.Player = uix.NewActor("PLAYER", &level.Actor{}, player) + s.Player = uix.NewActor("PLAYER", &level.Actor{Filename: filename}, player) s.Player.SetInventory(true) // player always can pick up items s.Player.MoveTo(spawn) s.drawing.AddActor(s.Player) diff --git a/pkg/scene.go b/pkg/scene.go index ed16c75..e6dc150 100644 --- a/pkg/scene.go +++ b/pkg/scene.go @@ -3,6 +3,7 @@ package doodle import ( "git.kirsle.net/SketchyMaze/doodle/pkg/gamepad" "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "git.kirsle.net/go/render/event" ) @@ -35,6 +36,9 @@ func (d *Doodle) Goto(scene Scene) error { d.Scene.Destroy() } + // Teardown exceptions modal (singleton windows so it can clean up). + exceptions.Teardown() + log.Info("Goto Scene: %s", scene.Name()) d.Scene = scene return d.Scene.Setup(d) diff --git a/pkg/scripting/events.go b/pkg/scripting/events.go index 8ff40ae..dfe86ca 100644 --- a/pkg/scripting/events.go +++ b/pkg/scripting/events.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/keybind" "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "github.com/dop251/goja" ) @@ -28,15 +29,17 @@ var ( // Events API for Doodad scripts. type Events struct { - runtime *goja.Runtime + vm *VM // pointer to parent VM + runtime *goja.Runtime // direct pointer to goja (VM.vm) registry map[string][]goja.Value lock sync.RWMutex } // NewEvents initializes the Events API. -func NewEvents(runtime *goja.Runtime) *Events { +func NewEvents(vm *VM) *Events { return &Events{ - runtime: runtime, + vm: vm, + runtime: vm.vm, registry: map[string][]goja.Value{}, } } @@ -105,7 +108,7 @@ func (e *Events) run(name string, args ...interface{}) error { // TODO EXCEPTIONS: I once saw a "runtime error: index out of range [-1]" // from an OnCollide handler between azu-white and thief that was crashing // the app, report this upstream nicely to the user. - log.Error("PANIC: JS %s handler: %s", name, err) + exceptions.Catch("PANIC: JS %s handler: %s", name, err) } }() @@ -129,6 +132,12 @@ func (e *Events) run(name string, args ...interface{}) error { // TODO EXCEPTIONS: this err is useful like // `ReferenceError: playerSpeed is not defined at :173:9(93)` // but wherever we're returning the err to isn't handling it! + exceptions.Catch( + "Scripting error in %s for %s:\n\n%s", + name, + e.vm.Name, + err, + ) log.Error("Scripting error on %s: %s", name, err) return err } diff --git a/pkg/scripting/exceptions/exceptions.go b/pkg/scripting/exceptions/exceptions.go new file mode 100644 index 0000000..b90e83d --- /dev/null +++ b/pkg/scripting/exceptions/exceptions.go @@ -0,0 +1,262 @@ +// Package exceptions handles JavaScript errors nicely for the game. +package exceptions + +import ( + "fmt" + "strings" + "sync" + + "git.kirsle.net/SketchyMaze/doodle/pkg/balance" + "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/native" + "git.kirsle.net/SketchyMaze/doodle/pkg/shmem" + "git.kirsle.net/go/render" + "git.kirsle.net/go/render/event" + "git.kirsle.net/go/ui" +) + +// The exception catching window is a singleton and appears on top with +// its own supervisor apart from whatever the game is currently doing. +var ( + Supervisor *ui.Supervisor + Window *ui.Window + Disabled bool // don't reopen the window again + lastException string // text of last exception + excLabel *string // trimmed exception label text + mu sync.RWMutex // thread safety + + // Configurables. + winSize = render.NewRect(380, 260) + excSize = render.NewRect(360, 140) + charsWide = 50 // to trim the exception text onto screen + charsTall = 9 +) + +func init() { + l := "No Exception Traceback" + excLabel = &l +} + +// Catch a JavaScript exception and maybe show it to the user. +func Catch(exc string, args ...interface{}) { + if len(args) > 0 { + exc = fmt.Sprintf(exc, args...) + } + + log.Error("[JS] Exception: %s", exc) + if Disabled { + return + } + + Setup() + + width, _ := shmem.CurrentRenderEngine.WindowSize() + Window.MoveTo(render.Point{ + X: (width / 2) - (Window.Size().W / 2), + Y: 60, + }) + + Window.Show() + lastException = exc + *excLabel = trim(exc) +} + +// Setup the global supervisor and window the first time - after the render engine has initialized, +// e.g., when you want the window to show up the first time. +func Setup() { + mu.Lock() + defer mu.Unlock() + + log.Info("Setup Exceptions") + + if Supervisor == nil { + Supervisor = ui.NewSupervisor() + } + + if Window == nil { + Window = MakeWindow(Exception{ + Supervisor: Supervisor, + Engine: shmem.CurrentRenderEngine, + }) + Window.Compute(shmem.CurrentRenderEngine) + Window.Supervise(Supervisor) + } +} + +// Teardown the exception UI, if it was loaded. This is done between Scene transitions in the game +// to reduce memory leaks in case the scenes will flush SDL2 caches or w/e. +func Teardown() { + log.Error("Teardown Exceptions") + mu.Lock() + defer mu.Unlock() + + if Window != nil { + Window = nil + } + + if Supervisor != nil { + Supervisor = nil + } +} + +// Handled returns true if the exception window handles the events this tick, e.g. +// so clicking on its window won't let your mouse click also hit things behind it. +func Handled(ev *event.State) bool { + if Window != nil && !Window.Hidden() { + // If they hit the Return/Escape key, dismiss the exception. + if ev.Enter || ev.Escape { + ev.Escape = false + Window.Close() + return true + } + + Supervisor.Loop(ev) + + // NOTE: if the window Close handler tears down the Supervisor, the + // previous call to Supervisor.Loop() may run it and then Supervisor + // no longer exists. + if Supervisor != nil && Supervisor.IsPointInWindow(shmem.Cursor) { + return true + } + } + return false +} + +// Loop allows the exception window to appear on game tick. +func Draw(e render.Engine) { + if Supervisor != nil { + Supervisor.Present(e) + } +} + +// Exception window to show scripting errors in doodads. +type Exception struct { + // Settings passed in by doodle + Supervisor *ui.Supervisor + Engine render.Engine +} + +// Function to trim the raw exception text so it fits neatly within the label. +// In case it's long, use the Copy button to copy to your clipboard. +func trim(input string) string { + var lines = []string{} + for _, line := range strings.Split(input, "\n") { + if len(lines) >= charsTall { + lines = lines[:charsTall] + break + } + + if len(line) > charsWide { + // Word wrap it. + for len(line) > charsWide { + lines = append(lines, line[:charsWide]) + line = line[charsWide:] + } + if len(line) > 0 { + lines = append(lines, line) + } + continue + } + + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +// MakeWindow initializes the window. +func MakeWindow(cfg Exception) *ui.Window { + window := ui.NewWindow("Exception") + window.Configure(ui.Config{ + Width: winSize.W, + Height: winSize.H, + Background: render.Red.Lighten(128), + }) + + window.Handle(ui.CloseWindow, func(ed ui.EventData) error { + Teardown() + return ui.ErrStopPropagation + }) + + header := ui.NewLabel(ui.Label{ + Text: "A JavaScript error has occurred in a doodad:", + Font: balance.UIFont, + }) + window.Pack(header, ui.Pack{ + Side: ui.N, + Padding: 8, + }) + + text := ui.NewLabel(ui.Label{ + TextVariable: excLabel, + Font: balance.ExceptionFont, + }) + text.Configure(ui.Config{ + BorderSize: 1, + BorderStyle: ui.BorderSunken, + Background: render.White, + Width: excSize.W, + Height: excSize.H, + }) + window.Pack(text, ui.Pack{ + Side: ui.N, + }) + + frame := ui.NewFrame("Button frame") + buttons := []struct { + label string + tooltip string + f func() + }{ + {"Dismiss", "", func() { + Window.Close() + }}, + {"Copy", "Copy the full text to clipboard", func() { + native.CopyToClipboard(lastException) + }}, + {"Don't show again", "Don't show errors like this again\nuntil your next play session", func() { + Disabled = true + Window.Close() + }}, + } + for i, button := range buttons { + button := button + + btn := ui.NewButton(button.label, ui.NewLabel(ui.Label{ + Text: button.label, + Font: balance.MenuFont, + })) + if i == 0 { + btn.SetStyle(&balance.ButtonPrimary) + } + + btn.Handle(ui.Click, func(ed ui.EventData) error { + button.f() + return nil + }) + + btn.Compute(cfg.Engine) + + // Tooltips? + if len(button.tooltip) > 0 { + ui.NewTooltip(btn, ui.Tooltip{ + Text: button.tooltip, + Edge: ui.Bottom, + }) + } + + cfg.Supervisor.Add(btn) + + frame.Pack(btn, ui.Pack{ + Side: ui.W, + PadX: 4, + Expand: true, + Fill: true, + }) + } + window.Pack(frame, ui.Pack{ + Side: ui.N, + PadY: 12, + }) + + return window +} diff --git a/pkg/scripting/js_api.go b/pkg/scripting/js_api.go index 117f65c..4e685d3 100644 --- a/pkg/scripting/js_api.go +++ b/pkg/scripting/js_api.go @@ -1,6 +1,7 @@ package scripting import ( + "fmt" "time" "git.kirsle.net/SketchyMaze/doodle/pkg/log" @@ -17,15 +18,24 @@ import ( // without exposing unintended API surface area in the process. type JSProxy map[string]interface{} +// ProxyLog wraps a console.log function to inject the script's name. +func ProxyLog(vm *VM, fn func(string, ...interface{})) func(string, ...interface{}) { + var prefix = fmt.Sprintf("[%s] ", vm.Name) + return func(msg string, v ...interface{}) { + fn(prefix+msg, v...) + } +} + // NewJSProxy initializes the API structure for JavaScript binding. func NewJSProxy(vm *VM) JSProxy { + return JSProxy{ // Console logging. "console": map[string]interface{}{ - "log": log.Info, - "debug": log.Debug, - "warn": log.Warn, - "error": log.Error, + "log": ProxyLog(vm, log.Info), + "debug": ProxyLog(vm, log.Debug), + "warn": ProxyLog(vm, log.Warn), + "error": ProxyLog(vm, log.Error), }, // Audio API. diff --git a/pkg/scripting/pubsub.go b/pkg/scripting/pubsub.go index ee9f01d..05526be 100644 --- a/pkg/scripting/pubsub.go +++ b/pkg/scripting/pubsub.go @@ -3,6 +3,7 @@ package scripting import ( "git.kirsle.net/SketchyMaze/doodle/lib/debugging" "git.kirsle.net/SketchyMaze/doodle/pkg/log" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "github.com/dop251/goja" ) @@ -28,7 +29,7 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) { defer func() { if err := recover(); err != nil { // TODO EXCEPTIONS - log.Error("RegisterPublishHooks(%s): %s", vm.Name, err) + exceptions.Catch("RegisterPublishHooks(%s): %s", vm.Name, err) debugging.PrintCallers() } }() diff --git a/pkg/scripting/vm.go b/pkg/scripting/vm.go index 6b86bff..3d4f709 100644 --- a/pkg/scripting/vm.go +++ b/pkg/scripting/vm.go @@ -50,7 +50,7 @@ func NewVM(name string) *VM { stop: make(chan bool, 1), subscribe: map[string][]goja.Value{}, } - vm.Events = NewEvents(vm.vm) + vm.Events = NewEvents(vm) return vm } diff --git a/pkg/uix/canvas_actors.go b/pkg/uix/canvas_actors.go index db714cf..b17d9cb 100644 --- a/pkg/uix/canvas_actors.go +++ b/pkg/uix/canvas_actors.go @@ -10,6 +10,7 @@ import ( "git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting" + "git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions" "git.kirsle.net/go/render" ) @@ -102,7 +103,14 @@ func (w *Canvas) InstallScripts() error { // Call the main() function. if err := vm.Main(); err != nil { - log.Error("main() for actor %s errored: %s", actor.ID(), err) + exceptions.Catch( + "Error in main() for actor %s:\n\n%s\n\nActor ID: %s\nFilename: %s\nPosition: %s", + actor.Actor.Filename, + err, + actor.ID(), + actor.Actor.Filename, + actor.Position(), + ) } }