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 <message>' 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).
This commit is contained in:
Noah 2022-09-24 21:58:01 -07:00
parent cd103f06c7
commit 653184b8f8
13 changed files with 356 additions and 13 deletions

View File

@ -174,6 +174,14 @@ var (
Color: render.Magenta, 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 // Small font
SmallFont = render.Text{ SmallFont = render.Text{
Size: 10, Size: 10,

View File

@ -14,6 +14,7 @@ import (
"git.kirsle.net/SketchyMaze/doodle/pkg/enum" "git.kirsle.net/SketchyMaze/doodle/pkg/enum"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/modal" "git.kirsle.net/SketchyMaze/doodle/pkg/modal"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@ -88,6 +89,29 @@ func (c Command) Run(d *Doodle) error {
case "extract-bindata": case "extract-bindata":
// Undocumented command to extract the binary of its assets. // Undocumented command to extract the binary of its assets.
return c.ExtractBindata(d, c.ArgsLiteral) 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 (<eval>:25:14(77))\n\n" +
"Actor ID: c3aa346b-be51-4bc4-94bb-f3adf5643830\n" +
"Filename: trapdoor-down.doodad\n" +
"Position: 643,266",
)
default: default:
return c.Default(d) return c.Default(d)
} }

View File

@ -19,6 +19,7 @@ import (
"git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen" "git.kirsle.net/SketchyMaze/doodle/pkg/modal/loadscreen"
"git.kirsle.net/SketchyMaze/doodle/pkg/native" "git.kirsle.net/SketchyMaze/doodle/pkg/native"
"git.kirsle.net/SketchyMaze/doodle/pkg/pattern" "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/shmem"
"git.kirsle.net/SketchyMaze/doodle/pkg/usercfg" "git.kirsle.net/SketchyMaze/doodle/pkg/usercfg"
"git.kirsle.net/SketchyMaze/doodle/pkg/windows" "git.kirsle.net/SketchyMaze/doodle/pkg/windows"
@ -170,7 +171,7 @@ func (d *Doodle) Run() error {
// Make sure no UI modals (alerts, confirms) // Make sure no UI modals (alerts, confirms)
// or loadscreen are currently visible. // or loadscreen are currently visible.
if !modal.Handled(ev) { if !modal.Handled(ev) && !exceptions.Handled(ev) {
// Global event handlers. // Global event handlers.
if keybind.Shutdown(ev) { if keybind.Shutdown(ev) {
if d.Debug { // fast exit in -debug mode. 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) loadscreen.Loop(render.NewRect(d.width, d.height), d.Engine)
// Draw modals on top of the game UI. // Draw modals on top of the game UI.
exceptions.Draw(d.Engine)
modal.Draw() modal.Draw()
// Draw the shell, always on top of UI and modals. // Draw the shell, always on top of UI and modals.

View File

@ -4,8 +4,10 @@
package native package native
import ( import (
"errors"
"image" "image"
"git.kirsle.net/SketchyMaze/doodle/pkg/shmem"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
"git.kirsle.net/go/render/sdl" "git.kirsle.net/go/render/sdl"
sdl2 "github.com/veandco/go-sdl2/sdl" sdl2 "github.com/veandco/go-sdl2/sdl"
@ -23,6 +25,14 @@ func HasTouchscreen(e render.Engine) bool {
return false 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. TextToImage takes an SDL2_TTF texture and makes it into a Go image.

View File

@ -1,3 +1,4 @@
//go:build js && wasm
// +build js,wasm // +build js,wasm
package native package native
@ -15,3 +16,7 @@ func HasTouchscreen(e render.Engine) bool {
func TextToImage(e render.Engine, text render.Text) (image.Image, error) { func TextToImage(e render.Engine, text render.Text) (image.Image, error) {
return nil, errors.New("not supported on WASM") return nil, errors.New("not supported on WASM")
} }
func CopyToClipboard(text string) error {
return errors.New("not supported on WASM")
}

View File

@ -471,7 +471,7 @@ func (s *PlayScene) installPlayerDoodad(filename string, spawn render.Point, cen
spawn = s.SpawnPoint 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.SetInventory(true) // player always can pick up items
s.Player.MoveTo(spawn) s.Player.MoveTo(spawn)
s.drawing.AddActor(s.Player) s.drawing.AddActor(s.Player)

View File

@ -3,6 +3,7 @@ package doodle
import ( import (
"git.kirsle.net/SketchyMaze/doodle/pkg/gamepad" "git.kirsle.net/SketchyMaze/doodle/pkg/gamepad"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions"
"git.kirsle.net/go/render/event" "git.kirsle.net/go/render/event"
) )
@ -35,6 +36,9 @@ func (d *Doodle) Goto(scene Scene) error {
d.Scene.Destroy() d.Scene.Destroy()
} }
// Teardown exceptions modal (singleton windows so it can clean up).
exceptions.Teardown()
log.Info("Goto Scene: %s", scene.Name()) log.Info("Goto Scene: %s", scene.Name())
d.Scene = scene d.Scene = scene
return d.Scene.Setup(d) return d.Scene.Setup(d)

View File

@ -7,6 +7,7 @@ import (
"git.kirsle.net/SketchyMaze/doodle/pkg/keybind" "git.kirsle.net/SketchyMaze/doodle/pkg/keybind"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@ -28,15 +29,17 @@ var (
// Events API for Doodad scripts. // Events API for Doodad scripts.
type Events struct { 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 registry map[string][]goja.Value
lock sync.RWMutex lock sync.RWMutex
} }
// NewEvents initializes the Events API. // NewEvents initializes the Events API.
func NewEvents(runtime *goja.Runtime) *Events { func NewEvents(vm *VM) *Events {
return &Events{ return &Events{
runtime: runtime, vm: vm,
runtime: vm.vm,
registry: map[string][]goja.Value{}, 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]" // 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 // from an OnCollide handler between azu-white and thief that was crashing
// the app, report this upstream nicely to the user. // 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 // TODO EXCEPTIONS: this err is useful like
// `ReferenceError: playerSpeed is not defined at <eval>:173:9(93)` // `ReferenceError: playerSpeed is not defined at <eval>:173:9(93)`
// but wherever we're returning the err to isn't handling it! // 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) log.Error("Scripting error on %s: %s", name, err)
return err return err
} }

View File

@ -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
}

View File

@ -1,6 +1,7 @@
package scripting package scripting
import ( import (
"fmt"
"time" "time"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
@ -17,15 +18,24 @@ import (
// without exposing unintended API surface area in the process. // without exposing unintended API surface area in the process.
type JSProxy map[string]interface{} 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. // NewJSProxy initializes the API structure for JavaScript binding.
func NewJSProxy(vm *VM) JSProxy { func NewJSProxy(vm *VM) JSProxy {
return JSProxy{ return JSProxy{
// Console logging. // Console logging.
"console": map[string]interface{}{ "console": map[string]interface{}{
"log": log.Info, "log": ProxyLog(vm, log.Info),
"debug": log.Debug, "debug": ProxyLog(vm, log.Debug),
"warn": log.Warn, "warn": ProxyLog(vm, log.Warn),
"error": log.Error, "error": ProxyLog(vm, log.Error),
}, },
// Audio API. // Audio API.

View File

@ -3,6 +3,7 @@ package scripting
import ( import (
"git.kirsle.net/SketchyMaze/doodle/lib/debugging" "git.kirsle.net/SketchyMaze/doodle/lib/debugging"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@ -28,7 +29,7 @@ func RegisterPublishHooks(s *Supervisor, vm *VM) {
defer func() { defer func() {
if err := recover(); err != nil { if err := recover(); err != nil {
// TODO EXCEPTIONS // TODO EXCEPTIONS
log.Error("RegisterPublishHooks(%s): %s", vm.Name, err) exceptions.Catch("RegisterPublishHooks(%s): %s", vm.Name, err)
debugging.PrintCallers() debugging.PrintCallers()
} }
}() }()

View File

@ -50,7 +50,7 @@ func NewVM(name string) *VM {
stop: make(chan bool, 1), stop: make(chan bool, 1),
subscribe: map[string][]goja.Value{}, subscribe: map[string][]goja.Value{},
} }
vm.Events = NewEvents(vm.vm) vm.Events = NewEvents(vm)
return vm return vm
} }

View File

@ -10,6 +10,7 @@ import (
"git.kirsle.net/SketchyMaze/doodle/pkg/level" "git.kirsle.net/SketchyMaze/doodle/pkg/level"
"git.kirsle.net/SketchyMaze/doodle/pkg/log" "git.kirsle.net/SketchyMaze/doodle/pkg/log"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting" "git.kirsle.net/SketchyMaze/doodle/pkg/scripting"
"git.kirsle.net/SketchyMaze/doodle/pkg/scripting/exceptions"
"git.kirsle.net/go/render" "git.kirsle.net/go/render"
) )
@ -102,7 +103,14 @@ func (w *Canvas) InstallScripts() error {
// Call the main() function. // Call the main() function.
if err := vm.Main(); err != nil { 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(),
)
} }
} }