diff --git a/.gitignore b/.gitignore index 7734e99..3d1e81f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ fonts/ maps/ bin/ dist/ +wasm/assets/ +*.wasm *.doodad docker/ubuntu docker/debian diff --git a/Building.md b/Building.md index 84c2109..d5b894e 100644 --- a/Building.md +++ b/Building.md @@ -27,6 +27,8 @@ Makefile commands for Linux: Build Tags below. * `make build-debug`: build a debug binary (not release-mode) to the `bin/` folder. See Build Tags below. +* `make wasm`: build the WebAssembly output +* `make wasm-serve`: build the WASM output and then run the server. * `make run`: run a local dev build of Doodle in debug mode * `make guitest`: run a local dev build in the GUITest scene * `make test`: run the test suite diff --git a/Makefile b/Makefile index 5031a4e..25a7ee3 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,17 @@ build-debug: go build $(LDFLAGS) -tags="developer" -i -o bin/doodle cmd/doodle/main.go go build $(LDFLAGS) -tags="developer" -i -o bin/doodad cmd/doodad/main.go +# `make wasm` builds the WebAssembly port. +.PHONY: wasm +wasm: + cd wasm && make + +# `make wasm-serve` builds and launches the WebAssembly server. +.PHONY: wasm-serve +wasm-serve: wasm + sh -c 'sleep 1; xdg-open http://localhost:8080/' & + cd wasm && go run server.go + # `make install` to install the Go binaries to your GOPATH. .PHONY: install install: diff --git a/lib/render/canvas/canvas.go b/lib/render/canvas/canvas.go new file mode 100644 index 0000000..c5eb82f --- /dev/null +++ b/lib/render/canvas/canvas.go @@ -0,0 +1,37 @@ +package canvas + +import ( + "syscall/js" +) + +// Canvas represents an HTML5 Canvas object. +type Canvas struct { + Value js.Value + ctx2d js.Value +} + +// GetCanvas gets an HTML5 Canvas object from the DOM. +func GetCanvas(id string) Canvas { + canvasEl := js.Global().Get("document").Call("getElementById", id) + canvas2d := canvasEl.Call("getContext", "2d") + + c := Canvas{ + Value: canvasEl, + ctx2d: canvas2d, + } + + canvasEl.Set("width", c.ClientW()) + canvasEl.Set("height", c.ClientH()) + + return c +} + +// ClientW returns the client width. +func (c Canvas) ClientW() int { + return c.Value.Get("clientWidth").Int() +} + +// ClientH returns the client height. +func (c Canvas) ClientH() int { + return c.Value.Get("clientHeight").Int() +} diff --git a/lib/render/canvas/draw.go b/lib/render/canvas/draw.go new file mode 100644 index 0000000..fd8d8d2 --- /dev/null +++ b/lib/render/canvas/draw.go @@ -0,0 +1,66 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// Methods here implement the drawing functions of the render.Engine + +// Clear the canvas to a certain color. +func (e *Engine) Clear(color render.Color) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", 0, 0, e.width, e.height) +} + +// SetTitle sets the window title. +func (e *Engine) SetTitle(title string) { + js.Global().Get("document").Set("title", title) +} + +// DrawPoint draws a pixel. +func (e *Engine) DrawPoint(color render.Color, point render.Point) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", + int(point.X), + int(point.Y), + 1, + 1, + ) +} + +// DrawLine draws a line between two points. +func (e *Engine) DrawLine(color render.Color, a, b render.Point) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + for pt := range render.IterLine2(a, b) { + e.canvas.ctx2d.Call("fillRect", + int(pt.X), + int(pt.Y), + 1, + 1, + ) + } +} + +// DrawRect draws a rectangle. +func (e *Engine) DrawRect(color render.Color, rect render.Rect) { + e.canvas.ctx2d.Set("strokeStyle", color.ToHex()) + e.canvas.ctx2d.Call("strokeRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} + +// DrawBox draws a filled rectangle. +func (e *Engine) DrawBox(color render.Color, rect render.Rect) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillRect", + int(rect.X), + int(rect.Y), + int(rect.W), + int(rect.H), + ) +} diff --git a/lib/render/canvas/engine.go b/lib/render/canvas/engine.go new file mode 100644 index 0000000..f3edd19 --- /dev/null +++ b/lib/render/canvas/engine.go @@ -0,0 +1,77 @@ +package canvas + +import ( + "time" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/lib/render" +) + +// Engine implements a rendering engine targeting an HTML canvas for +// WebAssembly targets. +type Engine struct { + canvas Canvas + startTime time.Time + width int + height int + ticks uint32 + + // Private fields. + events *events.State + running bool +} + +// New creates the Canvas Engine. +func New(canvasID string) (*Engine, error) { + canvas := GetCanvas(canvasID) + + engine := &Engine{ + canvas: canvas, + startTime: time.Now(), + events: events.New(), + width: canvas.ClientW(), + height: canvas.ClientH(), + } + + return engine, nil +} + +// WindowSize returns the size of the canvas window. +func (e *Engine) WindowSize() (w, h int) { + return e.canvas.ClientW(), e.canvas.ClientH() +} + +// GetTicks returns the current tick count. +func (e *Engine) GetTicks() uint32 { + return e.ticks +} + +// TO BE IMPLEMENTED... + +func (e *Engine) Setup() error { + return nil +} + +func (e *Engine) Present() error { + return nil +} + +func (e *Engine) NewBitmap(filename string) (render.Texturer, error) { + return nil, nil +} + +func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) { + +} + +// Delay for a moment. +func (e *Engine) Delay(delay uint32) { + time.Sleep(time.Duration(delay) * time.Millisecond) +} + +// Teardown tasks. +func (e *Engine) Teardown() {} + +func (e *Engine) Loop() error { + return nil +} diff --git a/lib/render/canvas/events.go b/lib/render/canvas/events.go new file mode 100644 index 0000000..6c3b74c --- /dev/null +++ b/lib/render/canvas/events.go @@ -0,0 +1,102 @@ +package canvas + +import ( + "syscall/js" + + "git.kirsle.net/apps/doodle/lib/events" + "git.kirsle.net/apps/doodle/pkg/log" +) + +// AddEventListeners sets up bindings to collect events from the browser. +func (e *Engine) AddEventListeners() { + s := e.events + + // Mouse movement. + e.canvas.Value.Call( + "addEventListener", + "mousemove", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + var ( + x = args[0].Get("pageX").Int() + y = args[0].Get("pageY").Int() + ) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(y)) + return nil + }), + ) + + // Mouse clicks. + for _, ev := range []string{"mouseup", "mousedown"} { + ev := ev + e.canvas.Value.Call( + "addEventListener", + ev, + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + var ( + x = args[0].Get("pageX").Int() + y = args[0].Get("pageY").Int() + which = args[0].Get("which").Int() + ) + + log.Info("Clicked at %d,%d", x, y) + + s.CursorX.Push(int32(x)) + s.CursorY.Push(int32(y)) + + // Is a mouse button pressed down? + checkDown := func(number int) bool { + if which == number { + return ev == "mousedown" + } + return false + } + + s.Button1.Push(checkDown(1)) + s.Button2.Push(checkDown(3)) + return false + }), + ) + } + + // Supress context menu. + e.canvas.Value.Call( + "addEventListener", + "contextmenu", + js.FuncOf(func(this js.Value, args []js.Value) interface{} { + args[0].Call("preventDefault") + return false + }), + ) + + // Keyboard keys + // js.Global().Get("document").Call( + // "addEventListener", + // "keydown", + // js.FuncOf(func(this js.Value, args []js.Value) interface{} { + // log.Info("key: %+v", args) + // var ( + // event = args[0] + // charCode = event.Get("charCode") + // key = event.Get("key").String() + // ) + // + // switch key { + // case "Enter": + // s.EnterKey.Push(true) + // // default: + // // s.KeyName.Push(key) + // } + // + // log.Info("keypress: code=%s key=%s", charCode, key) + // + // return nil + // }), + // ) +} + +// Poll for events. +func (e *Engine) Poll() (*events.State, error) { + return e.events, nil +} diff --git a/lib/render/canvas/text.go b/lib/render/canvas/text.go new file mode 100644 index 0000000..a4ce4aa --- /dev/null +++ b/lib/render/canvas/text.go @@ -0,0 +1,88 @@ +package canvas + +// Text rendering functions using the HTML 5 canvas. + +import ( + "fmt" + "path/filepath" + "strings" + + "git.kirsle.net/apps/doodle/lib/render" +) + +// FontFilenameToName converts a FontFilename to its CSS font name. +// +// The CSS font name is set to the base of the filename, without the .ttf +// file extension. For example, "fonts/DejaVuSans.ttf" uses the CSS font +// family name "DejaVuSans" and that's what this function returns. +// +// Fonts must be defined in the index.html style sheet when serving the +// wasm build of Doodle. +// +// If filename is "", returns "serif" as a sensible default. +func FontFilenameToName(filename string) string { + if filename == "" { + return "DejaVuSans,serif" + } + return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) +} + +// DrawText draws text on the canvas. +func (e *Engine) DrawText(text render.Text, point render.Point) error { + font := FontFilenameToName(text.FontFilename) + e.canvas.ctx2d.Set("font", + fmt.Sprintf("%dpx %s,serif", text.Size, font), + ) + + e.canvas.ctx2d.Set("textBaseline", "top") + + write := func(dx, dy int, color render.Color) { + e.canvas.ctx2d.Set("fillStyle", color.ToHex()) + e.canvas.ctx2d.Call("fillText", + text.Text, + int(point.X)+dx, + int(point.Y)+dy, + ) + } + + // Does the text have a stroke around it? + if text.Stroke != render.Invisible { + e.canvas.ctx2d.Set("fillStyle", text.Stroke.ToHex()) + write(-1, -1, text.Stroke) + write(-1, 0, text.Stroke) + write(-1, 1, text.Stroke) + write(1, -1, text.Stroke) + write(1, 0, text.Stroke) + write(1, 1, text.Stroke) + write(0, -1, text.Stroke) + write(0, 1, text.Stroke) + } + + // Does it have a drop shadow? + if text.Shadow != render.Invisible { + write(1, 1, text.Shadow) + } + + // Draw the text itself. + write(0, 0, text.Color) + + return nil +} + +// ComputeTextRect computes and returns a Rect for how large the text would +// appear if rendered. +func (e *Engine) ComputeTextRect(text render.Text) (render.Rect, error) { + font := FontFilenameToName(text.FontFilename) + e.canvas.ctx2d.Set("font", + fmt.Sprintf("%dpx %s,serif", text.Size, font), + ) + + measure := e.canvas.ctx2d.Call("measureText", text.Text) + rect := render.Rect{ + // TODO: the only TextMetrics widely supported in browsers is + // the width. For height, use the text size for now. + W: int32(measure.Get("width").Int()), + H: int32(text.Size), + } + return rect, nil +} diff --git a/lib/render/color.go b/lib/render/color.go index 699abb9..3ec3ac5 100644 --- a/lib/render/color.go +++ b/lib/render/color.go @@ -113,6 +113,14 @@ func (c Color) String() string { ) } +// ToHex converts a render.Color to standard #RRGGBB hexadecimal format. +func (c Color) ToHex() string { + return fmt.Sprintf( + "#%02x%02x%02x", + c.Red, c.Green, c.Blue, + ) +} + // ToColor converts a render.Color into a Go standard color.Color func (c Color) ToColor() color.RGBA { return color.RGBA{ diff --git a/lib/ui/main_window.go b/lib/ui/main_window.go index e12c154..b62450f 100644 --- a/lib/ui/main_window.go +++ b/lib/ui/main_window.go @@ -1,3 +1,5 @@ +// +build !js + package ui import ( diff --git a/pkg/doodle.go b/pkg/doodle.go index 5250fee..4f38117 100644 --- a/pkg/doodle.go +++ b/pkg/doodle.go @@ -64,6 +64,12 @@ func New(debug bool, engine render.Engine) *Doodle { return d } +// SetWindowSize sets the size of the Doodle window. +func (d *Doodle) SetWindowSize(width, height int) { + d.width = width + d.height = height +} + // Title returns the game's preferred window title. func (d *Doodle) Title() string { return fmt.Sprintf("%s v%s", branding.AppName, branding.Version) diff --git a/pkg/main_scene.go b/pkg/main_scene.go index 3c7fc30..2eb83ff 100644 --- a/pkg/main_scene.go +++ b/pkg/main_scene.go @@ -106,7 +106,7 @@ func (s *MainScene) Draw(d *Doodle) error { label := ui.NewLabel(ui.Label{ Text: branding.AppName, Font: render.Text{ - Size: 26, + Size: 46, Color: render.Pink, Stroke: render.SkyBlue, Shadow: render.Black, @@ -122,7 +122,7 @@ func (s *MainScene) Draw(d *Doodle) error { s.frame.Compute(d.Engine) s.frame.MoveTo(render.Point{ X: (int32(d.width) / 2) - (s.frame.Size().W / 2), - Y: 200, + Y: 260, }) s.frame.Present(d.Engine, s.frame.Point()) diff --git a/pkg/uix/canvas.go b/pkg/uix/canvas.go index 7d22faf..ccc5191 100644 --- a/pkg/uix/canvas.go +++ b/pkg/uix/canvas.go @@ -3,6 +3,7 @@ package uix import ( "fmt" "os" + "runtime" "strings" "git.kirsle.net/apps/doodle/lib/events" @@ -127,9 +128,11 @@ func (w *Canvas) LoadLevel(e render.Engine, level *level.Level) { // TODO: wallpaper paths filename := "assets/wallpapers/" + level.Wallpaper - if _, err := os.Stat(filename); os.IsNotExist(err) { - log.Error("LoadLevel: %s", err) - filename = "assets/wallpapers/notebook.png" // XXX TODO + if runtime.GOOS != "js" { + if _, err := os.Stat(filename); os.IsNotExist(err) { + log.Error("LoadLevel: %s", err) + filename = "assets/wallpapers/notebook.png" // XXX TODO + } } wp, err := wallpaper.FromFile(e, filename) diff --git a/pkg/userdir/userdir.go b/pkg/userdir/userdir.go index bdf817b..12ad41e 100644 --- a/pkg/userdir/userdir.go +++ b/pkg/userdir/userdir.go @@ -4,6 +4,7 @@ import ( "io/ioutil" "os" "path/filepath" + "runtime" "strings" "github.com/kirsle/configdir" @@ -38,9 +39,12 @@ func init() { FontDirectory = configdir.LocalCache(ConfigDirectoryName, "fonts") // Ensure all the directories exist. - configdir.MakePath(LevelDirectory) - configdir.MakePath(DoodadDirectory) - configdir.MakePath(FontDirectory) + // WASM: do not make paths in wasm. + if runtime.GOOS != "js" { + configdir.MakePath(LevelDirectory) + configdir.MakePath(DoodadDirectory) + configdir.MakePath(FontDirectory) + } } // LevelPath will turn a "simple" filename into an absolute path in the user's diff --git a/pkg/wallpaper/texture.go b/pkg/wallpaper/texture.go index 7f27030..ebdc87f 100644 --- a/pkg/wallpaper/texture.go +++ b/pkg/wallpaper/texture.go @@ -3,6 +3,7 @@ package wallpaper // The methods that deal in cached Textures for Doodle. import ( + "errors" "fmt" "image" "os" @@ -14,7 +15,10 @@ import ( // CornerTexture returns the Texture. func (wp *Wallpaper) CornerTexture(e render.Engine) (render.Texturer, error) { - fmt.Println("CornerTex") + if !wp.ready { + return nil, errors.New("wallpaper not ready") + } + if wp.tex.corner == nil { tex, err := texture(e, wp.corner, wp.Name+"c") wp.tex.corner = tex diff --git a/pkg/wallpaper/wallpaper.go b/pkg/wallpaper/wallpaper.go index 98482a8..5dacad7 100644 --- a/pkg/wallpaper/wallpaper.go +++ b/pkg/wallpaper/wallpaper.go @@ -5,6 +5,7 @@ import ( "image/draw" "os" "path/filepath" + "runtime" "strings" "git.kirsle.net/apps/doodle/lib/render" @@ -16,6 +17,10 @@ type Wallpaper struct { Format string // image file format Image *image.RGBA + // Ready status is set to true if the wallpaper loaded itself properly. + // Notably in WASM, wallpapers don't load currently. + ready bool + // Parsed values. quarterWidth int quarterHeight int @@ -51,6 +56,14 @@ func FromImage(e render.Engine, img *image.RGBA, name string) (*Wallpaper, error // If the renger.Engine is nil it will compute images but not pre-cache any // textures yet. func FromFile(e render.Engine, filename string) (*Wallpaper, error) { + // WASM: no support yet for wallpapers. + if runtime.GOOS == "js" { + return &Wallpaper{ + Name: strings.Split(filepath.Base(filename), ".")[0], + ready: false, + }, nil + } + fh, err := os.Open(filename) if err != nil { return nil, err @@ -75,6 +88,7 @@ func FromFile(e render.Engine, filename string) (*Wallpaper, error) { Name: strings.Split(filepath.Base(filename), ".")[0], Format: format, Image: rgba, + ready: true, } wp.cache(e) return wp, nil diff --git a/wasm/Makefile b/wasm/Makefile new file mode 100644 index 0000000..d465abc --- /dev/null +++ b/wasm/Makefile @@ -0,0 +1,5 @@ +all: + GOOS=js GOARCH=wasm go build -v -o doodle.wasm + +clean: + rm -f doodle.wasm diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 0000000..06a9427 --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,51 @@ +# WebAssembly Port + +## Build and Test + +Change to the wasm/ folder and type `make` to build `doodle.wasm` + +To test it with a local Go server, cd to the wasm/ folder and run +`go run server.go` and visit http://localhost:8080/ + +Copy the `fonts` and `assets` folders from the project root to the +wasm/ directory so they're available over HTTP. + +## wasm_exec.js + +To update the wasm_exec.js to match your version of Go: + +```bash +# Fedora: install golang-misc +sudo dnf install golang-misc + +# Copy the wasm_exec.js +cp $(go env GOROOT)/misc/wasm/wasm_exec.js ./ +``` + +## Font Support + +Fonts are implemented as CSS embedded fonts configured in +`wasm/index.html` + +The font family name should match the filename, sans .ttf extension, +for example "DejaVuSans-Bold". Doodle's internal logic converts a +FontFilename string like "./fonts/DejaVuSans.ttf" into the base name +to use as the font family. It also has fallbacks for sans-serif and +serif in case of any problems. + +## Known Bugs and Limitations + +* github.com/kirsle/golog + * The detection of an interactive terminal is broken in WASM. + * `terminal.IsTerminal(int(os.Stdout.Fd()))` + * As a workaround, comment it out and hardcode to `false` +* Userdir + * For WASM we'll want to use localStorage to store user drawings + instead of the userdir. +* Wallpaper support + * WASM can't use os.Open to read the wallpaper image, so will need + another method to load the image. + * Texture caching support isn't implemented yet to hold the four + corner textures of a wallpaper. + * As a workaround, added a `wallpaper.ready` boolean and relevant + functions exit early for WASM so wallpapers don't render at all. diff --git a/wasm/index.html b/wasm/index.html new file mode 100644 index 0000000..5dda789 --- /dev/null +++ b/wasm/index.html @@ -0,0 +1,54 @@ + + + + Project: Doodle + + + + + + + + + + + diff --git a/wasm/main_wasm.go b/wasm/main_wasm.go new file mode 100644 index 0000000..7996f38 --- /dev/null +++ b/wasm/main_wasm.go @@ -0,0 +1,84 @@ +// +build js,wasm + +package main + +import ( + "fmt" + + "git.kirsle.net/apps/doodle/lib/render" + "git.kirsle.net/apps/doodle/lib/render/canvas" + doodle "git.kirsle.net/apps/doodle/pkg" + "git.kirsle.net/apps/doodle/pkg/branding" + "git.kirsle.net/apps/doodle/pkg/log" +) + +func main() { + fmt.Printf("Hello world\n") + // testRawCanvas() + + // HTML5 Canvas engine. + engine, _ := canvas.New("canvas") + engine.AddEventListeners() + + game := doodle.New(true, engine) + game.SetupEngine() + + // Manually inform Doodle of the canvas size since it can't control + // the size on its own. + w, h := engine.WindowSize() + game.SetWindowSize(w, h) + + // game.Goto(&doodle.GUITestScene{}) + // game.Goto(&doodle.EditorScene{}) + + game.Run() +} + +func testRawCanvas() { + engine, _ := canvas.New("canvas") + engine.SetTitle( + fmt.Sprintf("%s v%s", branding.AppName, branding.Version), + ) + fmt.Printf("Got engine: %+v\n", engine) + engine.Clear(render.Green) + + for pt := range render.IterLine2(render.NewPoint(20, 20), render.NewPoint(300, 300)) { + engine.DrawPoint(render.Red, pt) + } + + engine.DrawLine(render.Blue, render.NewPoint(20, 300), render.NewPoint(300, 20)) + + engine.DrawRect(render.Black, render.Rect{ + X: 5, + Y: 5, + W: 10, + H: 10, + }) + engine.DrawBox(render.White, render.Rect{ + X: 5, + Y: 5, + W: 10, + H: 10, + }) + + engine.DrawBox(render.Purple, render.Rect{ + X: 25, + Y: 5, + W: 10, + H: 10, + }) + + engine.DrawText(render.Text{ + Text: "Hello world!", + FontFilename: "DejaVuSans", + Size: 14, + }, render.NewPoint(400, 400)) + + size, _ := engine.ComputeTextRect(render.Text{ + Text: "Hello world! blah blah", + FontFilename: "DejaVuSans", + Size: 14, + }) + log.Info("text rect: %+v", size) + _ = engine +} diff --git a/wasm/server.go b/wasm/server.go new file mode 100644 index 0000000..48c64db --- /dev/null +++ b/wasm/server.go @@ -0,0 +1,128 @@ +// +build disabled + +package main + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "os/exec" + "path/filepath" + + "github.com/fsnotify/fsnotify" +) + +func main() { + const wasm = "/doodle.wasm" + + // Watch the dev directory for changes. + go watchChanges() + + http.Handle("/", http.FileServer(http.Dir("."))) + http.Handle("/fonts", http.FileServer(http.Dir("../fonts/"))) + http.HandleFunc(wasm, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/wasm") + http.ServeFile(w, r, "."+wasm) + }) + + fmt.Println("Listening at http://localhost:8080/") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +// onChange handler to rebuild the wasm file automatically. +func onChange() { + out, err := exec.Command("make").Output() + if err != nil { + log.Printf("error: %s", err) + } + log.Printf("%s", out) + log.Printf("Doodle WASM file rebuilt") +} + +// Watch the Doodle source tree for changes to Go files and rebuild +// the wasm binary automatically. +func watchChanges() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("error: %s", err) + return + } + defer watcher.Close() + + done := make(chan bool) + go func() { + log.Println("Starting watch files loop") + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + log.Printf("event: %s", event) + if event.Op&fsnotify.Write == fsnotify.Write { + log.Printf("modified file: %s", event.Name) + onChange() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + + log.Printf("error: %s", err) + } + } + }() + + log.Println("Adding source directory to watcher") + dirs := crawlDirectory("../") + + // Watch all these folders. + for _, dir := range dirs { + err = watcher.Add(dir) + if err != nil { + log.Printf("error: %s", err) + } + } + <-done +} + +// Crawl the filesystem and return paths with Go files. +func crawlDirectory(root string) []string { + var ( + ext = ".go" + result []string + has bool + ) + + files, err := ioutil.ReadDir(root) + if err != nil { + log.Fatalln(err) + } + + for _, file := range files { + if file.Name() == ".git" { + continue + } + + // Recursively scan subdirectories. + if file.IsDir() { + result = append(result, + crawlDirectory(filepath.Join(root, file.Name()))..., + ) + continue + } + + // This root has a file we want? + if filepath.Ext(file.Name()) == ext { + has = true + } + } + + if has { + result = append(result, root) + } + + return result +} diff --git a/wasm/wasm_exec.js b/wasm/wasm_exec.js new file mode 100644 index 0000000..165d567 --- /dev/null +++ b/wasm/wasm_exec.js @@ -0,0 +1,465 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +(() => { + if (typeof global !== "undefined") { + // global already exists + } else if (typeof window !== "undefined") { + window.global = window; + } else if (typeof self !== "undefined") { + self.global = self; + } else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); + } + + // Map web browser API and Node.js API to a single common API (preferring web standards over Node.js API). + const isNodeJS = global.process && global.process.title === "node"; + if (isNodeJS) { + global.require = require; + global.fs = require("fs"); + + const nodeCrypto = require("crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + + const util = require("util"); + global.TextEncoder = util.TextEncoder; + global.TextDecoder = util.TextDecoder; + } else { + let outputBuf = ""; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + throw new Error("not implemented"); + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + open(path, flags, mode, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + read(fd, buffer, offset, length, position, callback) { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + callback(err); + }, + fsync(fd, callback) { + callback(null); + }, + }; + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + global.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.mem.buffer); + } + + const setInt64 = (addr, v) => { + mem().setUint32(addr + 0, v, true); + mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = mem().getUint32(addr + 0, true); + const high = mem().getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = mem().getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = mem().getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number") { + if (isNaN(v)) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 0, true); + return; + } + if (v === 0) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 1, true); + return; + } + mem().setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + mem().setFloat64(addr, 0, true); + return; + case null: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 2, true); + return; + case true: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 3, true); + return; + case false: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 4, true); + return; + } + + let ref = this._refs.get(v); + if (ref === undefined) { + ref = this._values.length; + this._values.push(v); + this._refs.set(v, ref); + } + let typeFlag = 0; + switch (typeof v) { + case "string": + typeFlag = 1; + break; + case "symbol": + typeFlag = 2; + break; + case "function": + typeFlag = 3; + break; + } + mem().setUint32(addr + 4, nanHead | typeFlag, true); + mem().setUint32(addr, ref, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + go: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + const code = mem().getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._refs; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = mem().getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func nanotime() int64 + "runtime.nanotime": (sp) => { + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { this._resume(); }, + getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + mem().setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + const id = mem().getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 56, result); + mem().setUint8(sp + 64, 1); + } catch (err) { + storeValue(sp + 56, err); + mem().setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + this._inst = instance; + this._values = [ // TODO: garbage collection + NaN, + 0, + null, + true, + false, + global, + this._inst.exports.mem, + this, + ]; + this._refs = new Map(); + this.exited = false; + + const mem = new DataView(this._inst.exports.mem.buffer) + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + let ptr = offset; + new Uint8Array(mem.buffer, offset, str.length + 1).set(encoder.encode(str + "\0")); + offset += str.length + (8 - (str.length % 8)); + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + + const keys = Object.keys(this.env).sort(); + argvPtrs.push(keys.length); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + + const argv = offset; + argvPtrs.forEach((ptr) => { + mem.setUint32(offset, ptr, true); + mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } + + if (isNodeJS) { + if (process.argv.length < 3) { + process.stderr.write("usage: go_js_wasm_exec [wasm binary] [arguments]\n"); + process.exit(1); + } + + const go = new Go(); + go.argv = process.argv.slice(2); + go.env = Object.assign({ TMPDIR: require("os").tmpdir() }, process.env); + go.exit = process.exit; + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { + process.on("exit", (code) => { // Node.js exits if no event handler is pending + if (code === 0 && !go.exited) { + // deadlock, make Go print error and stack traces + go._pendingEvent = { id: 0 }; + go._resume(); + } + }); + return go.run(result.instance); + }).catch((err) => { + throw err; + }); + } +})();