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:
parent
cd103f06c7
commit
653184b8f8
|
@ -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,
|
||||
|
|
|
@ -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 (<eval>: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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 <eval>: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
|
||||
}
|
||||
|
|
262
pkg/scripting/exceptions/exceptions.go
Normal file
262
pkg/scripting/exceptions/exceptions.go
Normal 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
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user