Repository: zserge/lorca Branch: master Commit: 36a77caf0fc7 Files: 25 Total size: 46.7 KB Directory structure: gitextract_tqfsxivb/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── chrome.go ├── chrome_test.go ├── examples/ │ ├── counter/ │ │ ├── build-linux.sh │ │ ├── build-macos.sh │ │ ├── build-windows.bat │ │ ├── icons/ │ │ │ └── icon.icns │ │ ├── main.go │ │ └── www/ │ │ └── index.html │ ├── hello/ │ │ └── main.go │ └── stopwatch/ │ └── main.go ├── export.go ├── go.mod ├── go.sum ├── locate.go ├── locate_test.go ├── messagebox.go ├── messagebox_windows.go ├── ui.go ├── ui_test.go ├── value.go └── value_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI Pipeline on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - name: Install Chrome run: | sudo apt-get update sudo apt-get install -yqq google-chrome-stable - name: Install Go uses: actions/setup-go@v2 with: go-version: '1.16' - name: Run tests run: go test -v -race ./... - name: Build examples env: CGO_ENABLED: 0 run: | go build -o example-hello ./examples/hello go build -o example-stopwatch ./examples/stopwatch go build -o example-counter ./examples/counter ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib example/Example.app # Test binary, build with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out ## JetBrains Idea and Goland /.idea/**/*.* /.idea/\$CACHE_FILE\$ /.idea/dataSources/ !/.idea/inspectionProfiles/Project_Default.xml !/.idea/dictionaries/*.xml !/.idea/go.xml ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Serge Zaitsev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Lorca [![Build Status](https://img.shields.io/github/workflow/status/zserge/lorca/CI%20Pipeline)](https://github.com/zserge/lorca) [![GoDoc](https://godoc.org/github.com/zserge/lorca?status.svg)](https://godoc.org/github.com/zserge/lorca) [![Go Report Card](https://goreportcard.com/badge/github.com/zserge/lorca)](https://goreportcard.com/report/github.com/zserge/lorca)
Lorca

A very small library to build modern HTML5 desktop apps in Go. It uses Chrome browser as a UI layer. Unlike Electron it doesn't bundle Chrome into the app package, but rather reuses the one that is already installed. Lorca establishes a connection to the browser window and allows calling Go code from the UI and manipulating UI from Go in a seamless manner.


## Features * Pure Go library (no cgo) with a very simple API * Small application size (normally 5-10MB) * Best of both worlds - the whole power of HTML/CSS to make your UI look good, combined with Go performance and ease of development * Expose Go functions/methods and call them from JavaScript * Call arbitrary JavaScript code from Go * Asynchronous flow between UI and main app in both languages (async/await and Goroutines) * Supports loading web UI from the local web server or via data URL * Supports testing your app with the UI in the headless mode * Supports multiple app windows * Supports packaging and branding (e.g. custom app icons). Packaging for all three OS can be done on a single machine using GOOS and GOARCH variables. Also, limitations by design: * Requires Chrome/Chromium >= 70 to be installed. * No control over the Chrome window yet (e.g. you can't remove border, make it transparent, control position or size). * No window menu (tray menus and native OS dialogs are still possible via 3rd-party libraries) If you want to have more control of the browser window - consider using [webview](https://github.com/zserge/webview) library with a similar API, so migration would be smooth. ## Example ```go ui, _ := lorca.New("", "", 480, 320) defer ui.Close() // Bind Go function to be available in JS. Go function may be long-running and // blocking - in JS it's represented with a Promise. ui.Bind("add", func(a, b int) int { return a + b }) // Call JS function from Go. Functions may be asynchronous, i.e. return promises n := ui.Eval(`Math.random()`).Float() fmt.Println(n) // Call JS that calls Go and so on and so on... m := ui.Eval(`add(2, 3)`).Int() fmt.Println(m) // Wait for the browser window to be closed <-ui.Done() ```

Also, see [examples](examples) for more details about binding functions and packaging binaries. ## Hello World Here are the steps to run the hello world example. ``` cd examples/counter go get go run ./ ``` ## How it works Under the hood Lorca uses [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) to instrument on a Chrome instance. First Lorca tries to locate your installed Chrome, starts a remote debugging instance binding to an ephemeral port and reads from `stderr` for the actual WebSocket endpoint. Then Lorca opens a new client connection to the WebSocket server, and instruments Chrome by sending JSON messages of Chrome DevTools Protocol methods via WebSocket. JavaScript functions are evaluated in Chrome, while Go functions actually run in Go runtime and returned values are sent to Chrome. ## What's in a name? > There is kind of a legend, that before his execution Garcia Lorca have seen a > sunrise over the heads of the soldiers and he said "And yet, the sun rises...". > Probably it was the beginning of a poem. (J. Brodsky) Lorca is an anagram of [Carlo](https://github.com/GoogleChromeLabs/carlo/), a project with a similar goal for Node.js. ## License Code is distributed under MIT license, feel free to use it in your proprietary projects as well. ================================================ FILE: chrome.go ================================================ package lorca import ( "bufio" "encoding/json" "errors" "fmt" "io" "io/ioutil" "log" "os/exec" "regexp" "sync" "sync/atomic" "golang.org/x/net/websocket" ) type h = map[string]interface{} // Result is a struct for the resulting value of the JS expression or an error. type result struct { Value json.RawMessage Err error } type bindingFunc func(args []json.RawMessage) (interface{}, error) // Msg is a struct for incoming messages (results and async events) type msg struct { ID int `json:"id"` Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` Method string `json:"method"` Params json.RawMessage `json:"params"` } type chrome struct { sync.Mutex cmd *exec.Cmd ws *websocket.Conn id int32 target string session string window int pending map[int]chan result bindings map[string]bindingFunc } func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, error) { // The first two IDs are used internally during the initialization c := &chrome{ id: 2, pending: map[int]chan result{}, bindings: map[string]bindingFunc{}, } // Start chrome process c.cmd = exec.Command(chromeBinary, args...) pipe, err := c.cmd.StderrPipe() if err != nil { return nil, err } if err := c.cmd.Start(); err != nil { return nil, err } // Wait for websocket address to be printed to stderr re := regexp.MustCompile(`^DevTools listening on (ws://.*?)\r?\n$`) m, err := readUntilMatch(pipe, re) if err != nil { c.kill() return nil, err } wsURL := m[1] // Open a websocket c.ws, err = websocket.Dial(wsURL, "", "http://127.0.0.1") if err != nil { c.kill() return nil, err } // Find target and initialize session c.target, err = c.findTarget() if err != nil { c.kill() return nil, err } c.session, err = c.startSession(c.target) if err != nil { c.kill() return nil, err } go c.readLoop() for method, args := range map[string]h{ "Page.enable": nil, "Target.setAutoAttach": {"autoAttach": true, "waitForDebuggerOnStart": false}, "Network.enable": nil, "Runtime.enable": nil, "Security.enable": nil, "Performance.enable": nil, "Log.enable": nil, } { if _, err := c.send(method, args); err != nil { c.kill() c.cmd.Wait() return nil, err } } if !contains(args, "--headless") { win, err := c.getWindowForTarget(c.target) if err != nil { c.kill() return nil, err } c.window = win.WindowID } return c, nil } func (c *chrome) findTarget() (string, error) { err := websocket.JSON.Send(c.ws, h{ "id": 0, "method": "Target.setDiscoverTargets", "params": h{"discover": true}, }) if err != nil { return "", err } for { m := msg{} if err = websocket.JSON.Receive(c.ws, &m); err != nil { return "", err } else if m.Method == "Target.targetCreated" { target := struct { TargetInfo struct { Type string `json:"type"` ID string `json:"targetId"` } `json:"targetInfo"` }{} if err := json.Unmarshal(m.Params, &target); err != nil { return "", err } else if target.TargetInfo.Type == "page" { return target.TargetInfo.ID, nil } } } } func (c *chrome) startSession(target string) (string, error) { err := websocket.JSON.Send(c.ws, h{ "id": 1, "method": "Target.attachToTarget", "params": h{"targetId": target}, }) if err != nil { return "", err } for { m := msg{} if err = websocket.JSON.Receive(c.ws, &m); err != nil { return "", err } else if m.ID == 1 { if m.Error != nil { return "", errors.New("Target error: " + string(m.Error)) } session := struct { ID string `json:"sessionId"` }{} if err := json.Unmarshal(m.Result, &session); err != nil { return "", err } return session.ID, nil } } } // WindowState defines the state of the Chrome window, possible values are // "normal", "maximized", "minimized" and "fullscreen". type WindowState string const ( // WindowStateNormal defines a normal state of the browser window WindowStateNormal WindowState = "normal" // WindowStateMaximized defines a maximized state of the browser window WindowStateMaximized WindowState = "maximized" // WindowStateMinimized defines a minimized state of the browser window WindowStateMinimized WindowState = "minimized" // WindowStateFullscreen defines a fullscreen state of the browser window WindowStateFullscreen WindowState = "fullscreen" ) // Bounds defines settable window properties. type Bounds struct { Left int `json:"left"` Top int `json:"top"` Width int `json:"width"` Height int `json:"height"` WindowState WindowState `json:"windowState"` } type windowTargetMessage struct { WindowID int `json:"windowId"` Bounds Bounds `json:"bounds"` } func (c *chrome) getWindowForTarget(target string) (windowTargetMessage, error) { var m windowTargetMessage msg, err := c.send("Browser.getWindowForTarget", h{"targetId": target}) if err != nil { return m, err } err = json.Unmarshal(msg, &m) return m, err } type targetMessageTemplate struct { ID int `json:"id"` Method string `json:"method"` Params struct { Name string `json:"name"` Payload string `json:"payload"` ID int `json:"executionContextId"` Args []struct { Type string `json:"type"` Value interface{} `json:"value"` } `json:"args"` } `json:"params"` Error struct { Message string `json:"message"` } `json:"error"` Result json.RawMessage `json:"result"` } type targetMessage struct { targetMessageTemplate Result struct { Result struct { Type string `json:"type"` Subtype string `json:"subtype"` Description string `json:"description"` Value json.RawMessage `json:"value"` ObjectID string `json:"objectId"` } `json:"result"` Exception struct { Exception struct { Value json.RawMessage `json:"value"` } `json:"exception"` } `json:"exceptionDetails"` } `json:"result"` } func (c *chrome) readLoop() { for { m := msg{} if err := websocket.JSON.Receive(c.ws, &m); err != nil { return } if m.Method == "Target.receivedMessageFromTarget" { params := struct { SessionID string `json:"sessionId"` Message string `json:"message"` }{} json.Unmarshal(m.Params, ¶ms) if params.SessionID != c.session { continue } res := targetMessage{} json.Unmarshal([]byte(params.Message), &res) if res.ID == 0 && res.Method == "Runtime.consoleAPICalled" || res.Method == "Runtime.exceptionThrown" { log.Println(params.Message) } else if res.ID == 0 && res.Method == "Runtime.bindingCalled" { payload := struct { Name string `json:"name"` Seq int `json:"seq"` Args []json.RawMessage `json:"args"` }{} json.Unmarshal([]byte(res.Params.Payload), &payload) c.Lock() binding, ok := c.bindings[res.Params.Name] c.Unlock() if ok { jsString := func(v interface{}) string { b, _ := json.Marshal(v); return string(b) } go func() { result, error := "", `""` if r, err := binding(payload.Args); err != nil { error = jsString(err.Error()) } else if b, err := json.Marshal(r); err != nil { error = jsString(err.Error()) } else { result = string(b) } expr := fmt.Sprintf(` if (%[4]s) { window['%[1]s']['errors'].get(%[2]d)(%[4]s); } else { window['%[1]s']['callbacks'].get(%[2]d)(%[3]s); } window['%[1]s']['callbacks'].delete(%[2]d); window['%[1]s']['errors'].delete(%[2]d); `, payload.Name, payload.Seq, result, error) c.send("Runtime.evaluate", h{"expression": expr, "contextId": res.Params.ID}) }() } continue } c.Lock() resc, ok := c.pending[res.ID] delete(c.pending, res.ID) c.Unlock() if !ok { continue } if res.Error.Message != "" { resc <- result{Err: errors.New(res.Error.Message)} } else if res.Result.Exception.Exception.Value != nil { resc <- result{Err: errors.New(string(res.Result.Exception.Exception.Value))} } else if res.Result.Result.Type == "object" && res.Result.Result.Subtype == "error" { resc <- result{Err: errors.New(res.Result.Result.Description)} } else if res.Result.Result.Type != "" { resc <- result{Value: res.Result.Result.Value} } else { res := targetMessageTemplate{} json.Unmarshal([]byte(params.Message), &res) resc <- result{Value: res.Result} } } else if m.Method == "Target.targetDestroyed" { params := struct { TargetID string `json:"targetId"` }{} json.Unmarshal(m.Params, ¶ms) if params.TargetID == c.target { c.kill() return } } } } func (c *chrome) send(method string, params h) (json.RawMessage, error) { id := atomic.AddInt32(&c.id, 1) b, err := json.Marshal(h{"id": int(id), "method": method, "params": params}) if err != nil { return nil, err } resc := make(chan result) c.Lock() c.pending[int(id)] = resc c.Unlock() if err := websocket.JSON.Send(c.ws, h{ "id": int(id), "method": "Target.sendMessageToTarget", "params": h{"message": string(b), "sessionId": c.session}, }); err != nil { return nil, err } res := <-resc return res.Value, res.Err } func (c *chrome) load(url string) error { _, err := c.send("Page.navigate", h{"url": url}) return err } func (c *chrome) eval(expr string) (json.RawMessage, error) { return c.send("Runtime.evaluate", h{"expression": expr, "awaitPromise": true, "returnByValue": true}) } func (c *chrome) bind(name string, f bindingFunc) error { c.Lock() // check if binding already exists _, exists := c.bindings[name] c.bindings[name] = f c.Unlock() if exists { // Just replace callback and return, as the binding was already added to js // and adding it again would break it. return nil } if _, err := c.send("Runtime.addBinding", h{"name": name}); err != nil { return err } script := fmt.Sprintf(`(() => { const bindingName = '%s'; const binding = window[bindingName]; window[bindingName] = async (...args) => { const me = window[bindingName]; let errors = me['errors']; let callbacks = me['callbacks']; if (!callbacks) { callbacks = new Map(); me['callbacks'] = callbacks; } if (!errors) { errors = new Map(); me['errors'] = errors; } const seq = (me['lastSeq'] || 0) + 1; me['lastSeq'] = seq; const promise = new Promise((resolve, reject) => { callbacks.set(seq, resolve); errors.set(seq, reject); }); binding(JSON.stringify({name: bindingName, seq, args})); return promise; }})(); `, name) _, err := c.send("Page.addScriptToEvaluateOnNewDocument", h{"source": script}) if err != nil { return err } _, err = c.eval(script) return err } func (c *chrome) setBounds(b Bounds) error { if b.WindowState == "" { b.WindowState = WindowStateNormal } param := h{"windowId": c.window, "bounds": b} if b.WindowState != WindowStateNormal { param["bounds"] = h{"windowState": b.WindowState} } _, err := c.send("Browser.setWindowBounds", param) return err } func (c *chrome) bounds() (Bounds, error) { result, err := c.send("Browser.getWindowBounds", h{"windowId": c.window}) if err != nil { return Bounds{}, err } bounds := struct { Bounds Bounds `json:"bounds"` }{} err = json.Unmarshal(result, &bounds) return bounds.Bounds, err } func (c *chrome) pdf(width, height int) ([]byte, error) { result, err := c.send("Page.printToPDF", h{ "paperWidth": float32(width) / 96, "paperHeight": float32(height) / 96, }) if err != nil { return nil, err } pdf := struct { Data []byte `json:"data"` }{} err = json.Unmarshal(result, &pdf) return pdf.Data, err } func (c *chrome) png(x, y, width, height int, bg uint32, scale float32) ([]byte, error) { if x == 0 && y == 0 && width == 0 && height == 0 { // By default either use SVG size if it's an SVG, or use A4 page size bounds, err := c.eval(`document.rootElement ? [document.rootElement.x.baseVal.value, document.rootElement.y.baseVal.value, document.rootElement.width.baseVal.value, document.rootElement.height.baseVal.value] : [0,0,816,1056]`) if err != nil { return nil, err } rect := make([]int, 4) if err := json.Unmarshal(bounds, &rect); err != nil { return nil, err } x, y, width, height = rect[0], rect[1], rect[2], rect[3] } _, err := c.send("Emulation.setDefaultBackgroundColorOverride", h{ "color": h{ "r": (bg >> 16) & 0xff, "g": (bg >> 8) & 0xff, "b": bg & 0xff, "a": (bg >> 24) & 0xff, }, }) if err != nil { return nil, err } result, err := c.send("Page.captureScreenshot", h{ "clip": h{ "x": x, "y": y, "width": width, "height": height, "scale": scale, }, }) if err != nil { return nil, err } pdf := struct { Data []byte `json:"data"` }{} err = json.Unmarshal(result, &pdf) return pdf.Data, err } func (c *chrome) kill() error { if c.ws != nil { if err := c.ws.Close(); err != nil { return err } } // TODO: cancel all pending requests if state := c.cmd.ProcessState; state == nil || !state.Exited() { return c.cmd.Process.Kill() } return nil } func readUntilMatch(r io.ReadCloser, re *regexp.Regexp) ([]string, error) { br := bufio.NewReader(r) for { if line, err := br.ReadString('\n'); err != nil { r.Close() return nil, err } else if m := re.FindStringSubmatch(line); m != nil { go io.Copy(ioutil.Discard, br) return m, nil } } } func contains(arr []string, x string) bool { for _, n := range arr { if x == n { return true } } return false } ================================================ FILE: chrome_test.go ================================================ package lorca import ( "encoding/json" "errors" "strings" "sync" "sync/atomic" "testing" ) func TestChromeEval(t *testing.T) { c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") if err != nil { t.Fatal(err) } defer c.kill() for _, test := range []struct { Expr string Result string Error string }{ {Expr: ``, Result: ``}, {Expr: `42`, Result: `42`}, {Expr: `2+3`, Result: `5`}, {Expr: `(() => ({x: 5, y: 7}))()`, Result: `{"x":5,"y":7}`}, {Expr: `(() => ([1,'foo',false]))()`, Result: `[1,"foo",false]`}, {Expr: `((a, b) => a*b)(3, 7)`, Result: `21`}, {Expr: `Promise.resolve(42)`, Result: `42`}, {Expr: `Promise.reject('foo')`, Error: `"foo"`}, {Expr: `throw "bar"`, Error: `"bar"`}, {Expr: `2+`, Error: `SyntaxError: Unexpected end of input`}, } { result, err := c.eval(test.Expr) if err != nil { if err.Error() != test.Error { t.Fatal(test.Expr, err, test.Error) } } else if string(result) != test.Result { t.Fatal(test.Expr, string(result), test.Result) } } } func TestChromeLoad(t *testing.T) { c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") if err != nil { t.Fatal(err) } defer c.kill() if err := c.load("data:text/html,Hello"); err != nil { t.Fatal(err) } for i := 0; i < 10; i++ { url, err := c.eval(`window.location.href`) if err != nil { t.Fatal(err) } if strings.HasPrefix(string(url), `"data:text/html,`) { break } } if res, err := c.eval(`document.body ? document.body.innerText : new Promise(res => window.onload = () => res(document.body.innerText))`); err != nil { t.Fatal(err) } else if string(res) != `"Hello"` { t.Fatal(res) } } func TestChromeBind(t *testing.T) { c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") if err != nil { t.Fatal(err) } defer c.kill() if err := c.bind("add", func(args []json.RawMessage) (interface{}, error) { a, b := 0, 0 if len(args) != 2 { return nil, errors.New("2 arguments expected") } if err := json.Unmarshal(args[0], &a); err != nil { return nil, err } if err := json.Unmarshal(args[1], &b); err != nil { return nil, err } return a + b, nil }); err != nil { t.Fatal(err) } if res, err := c.eval(`window.add(2, 3)`); err != nil { t.Fatal(err) } else if string(res) != `5` { t.Fatal(string(res)) } if res, err := c.eval(`window.add("foo", "bar")`); err == nil { t.Fatal(string(res), err) } if res, err := c.eval(`window.add(1, 2, 3)`); err == nil { t.Fatal(res, err) } } func TestChromeAsync(t *testing.T) { c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") if err != nil { t.Fatal(err) } defer c.kill() if err := c.bind("len", func(args []json.RawMessage) (interface{}, error) { return len(args[0]), nil }); err != nil { t.Fatal(err) } wg := &sync.WaitGroup{} n := 10 failed := int32(0) wg.Add(n) for i := 0; i < n; i++ { go func(i int) { defer wg.Done() v, err := c.eval("len('hello')") if string(v) != `7` { atomic.StoreInt32(&failed, 1) } else if err != nil { atomic.StoreInt32(&failed, 2) } }(i) } wg.Wait() if status := atomic.LoadInt32(&failed); status != 0 { t.Fatal() } } ================================================ FILE: examples/counter/build-linux.sh ================================================ #!/bin/sh APP=lorca-example APPDIR=${APP}_1.0.0 mkdir -p $APPDIR/usr/bin mkdir -p $APPDIR/usr/share/applications mkdir -p $APPDIR/usr/share/icons/hicolor/1024x1024/apps mkdir -p $APPDIR/usr/share/icons/hicolor/256x256/apps mkdir -p $APPDIR/DEBIAN go build -o $APPDIR/usr/bin/$APP cp icons/icon.png $APPDIR/usr/share/icons/hicolor/1024x1024/apps/${APP}.png cp icons/icon.png $APPDIR/usr/share/icons/hicolor/256x256/apps/${APP}.png cat > $APPDIR/usr/share/applications/${APP}.desktop << EOF [Desktop Entry] Version=1.0 Type=Application Name=$APP Exec=$APP Icon=$APP Terminal=false StartupWMClass=Lorca EOF cat > $APPDIR/DEBIAN/control << EOF Package: ${APP} Version: 1.0-0 Section: base Priority: optional Architecture: amd64 Maintainer: Serge Zaitsev Description: Example for Lorca GUI toolkit EOF dpkg-deb --build $APPDIR ================================================ FILE: examples/counter/build-macos.sh ================================================ #!/bin/sh APP="Example.app" mkdir -p $APP/Contents/{MacOS,Resources} go build -o $APP/Contents/MacOS/lorca-example cat > $APP/Contents/Info.plist << EOF CFBundleExecutable lorca-example CFBundleIconFile icon.icns CFBundleIdentifier com.zserge.lorca.example EOF cp icons/icon.icns $APP/Contents/Resources/icon.icns find $APP ================================================ FILE: examples/counter/build-windows.bat ================================================ @echo off go generate go build -ldflags "-H windowsgui" -o lorca-example.exe ================================================ FILE: examples/counter/main.go ================================================ package main import ( "embed" "fmt" "log" "net" "net/http" "os" "os/signal" "runtime" "sync" "github.com/zserge/lorca" ) //go:embed www var fs embed.FS // Go types that are bound to the UI must be thread-safe, because each binding // is executed in its own goroutine. In this simple case we may use atomic // operations, but for more complex cases one should use proper synchronization. type counter struct { sync.Mutex count int } func (c *counter) Add(n int) { c.Lock() defer c.Unlock() c.count = c.count + n } func (c *counter) Value() int { c.Lock() defer c.Unlock() return c.count } func main() { args := []string{} if runtime.GOOS == "linux" { args = append(args, "--class=Lorca") } ui, err := lorca.New("", "", 480, 320, args...) if err != nil { log.Fatal(err) } defer ui.Close() // A simple way to know when UI is ready (uses body.onload event in JS) ui.Bind("start", func() { log.Println("UI is ready") }) // Create and bind Go object to the UI c := &counter{} ui.Bind("counterAdd", c.Add) ui.Bind("counterValue", c.Value) // Load HTML. // You may also use `data:text/html,` approach to load initial HTML, // e.g: ui.Load("data:text/html," + url.PathEscape(html)) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { log.Fatal(err) } defer ln.Close() go http.Serve(ln, http.FileServer(http.FS(fs))) ui.Load(fmt.Sprintf("http://%s/www", ln.Addr())) // You may use console.log to debug your JS code, it will be printed via // log.Println(). Also exceptions are printed in a similar manner. ui.Eval(` console.log("Hello, world!"); console.log('Multiple values:', [1, false, {"x":5}]); `) // Wait until the interrupt signal arrives or browser window is closed sigc := make(chan os.Signal) signal.Notify(sigc, os.Interrupt) select { case <-sigc: case <-ui.Done(): } log.Println("exiting...") } ================================================ FILE: examples/counter/www/index.html ================================================ Counter
+1
-1
================================================ FILE: examples/hello/main.go ================================================ package main import ( "log" "net/url" "github.com/zserge/lorca" ) func main() { // Create UI with basic HTML passed via data URI ui, err := lorca.New("data:text/html,"+url.PathEscape(` Hello

Hello, world!

`), "", 480, 320) if err != nil { log.Fatal(err) } defer ui.Close() // Wait until UI window is closed <-ui.Done() } ================================================ FILE: examples/stopwatch/main.go ================================================ package main import ( "fmt" "log" "net/url" "sync/atomic" "time" "github.com/zserge/lorca" ) func main() { ui, err := lorca.New("", "", 480, 320) if err != nil { log.Fatal(err) } defer ui.Close() // Data model: number of ticks ticks := uint32(0) // Channel to connect UI events with the background ticking goroutine togglec := make(chan bool) // Bind Go functions to JS ui.Bind("toggle", func() { togglec <- true }) ui.Bind("reset", func() { atomic.StoreUint32(&ticks, 0) ui.Eval(`document.querySelector('.timer').innerText = '0'`) }) // Load HTML after Go functions are bound to JS ui.Load("data:text/html," + url.PathEscape(`
`)) // Start ticker goroutine go func() { t := time.NewTicker(100 * time.Millisecond) for { select { case <-t.C: // Every 100ms increate number of ticks and update UI ui.Eval(fmt.Sprintf(`document.querySelector('.timer').innerText = 0.1*%d`, atomic.AddUint32(&ticks, 1))) case <-togglec: // If paused - wait for another toggle event to unpause <-togglec } } }() <-ui.Done() } ================================================ FILE: export.go ================================================ package lorca import ( "fmt" "io/ioutil" "os" ) const ( // PageA4Width is a width of an A4 page in pixels at 96dpi PageA4Width = 816 // PageA4Height is a height of an A4 page in pixels at 96dpi PageA4Height = 1056 ) // PDF converts a given URL (may be a local file) to a PDF file. Script is // evaluated before the page is printed to PDF, you may modify the contents of // the page there of wait until the page is fully rendered. Width and height // are page bounds in pixels. PDF by default uses 96dpi density. For A4 page // you may use PageA4Width and PageA4Height constants. func PDF(url, script string, width, height int) ([]byte, error) { return doHeadless(url, func(c *chrome) ([]byte, error) { if _, err := c.eval(script); err != nil { return nil, err } return c.pdf(width, height) }) } // PNG converts a given URL (may be a local file) to a PNG image. Script is // evaluated before the "screenshot" is taken, so you can modify the contents // of a URL there. Image bounds are provides in pixels. Background is in ARGB // format, the default value of zero keeps the background transparent. Scale // allows zooming the page in and out. // // This function is most convenient to convert SVG to PNG of different sizes, // for example when preparing Lorca app icons. func PNG(url, script string, x, y, width, height int, bg uint32, scale float32) ([]byte, error) { return doHeadless(url, func(c *chrome) ([]byte, error) { if _, err := c.eval(script); err != nil { return nil, err } return c.png(x, y, width, height, bg, scale) }) } func doHeadless(url string, f func(c *chrome) ([]byte, error)) ([]byte, error) { dir, err := ioutil.TempDir("", "lorca") if err != nil { return nil, err } defer os.RemoveAll(dir) args := append(defaultChromeArgs, fmt.Sprintf("--user-data-dir=%s", dir), "--remote-debugging-port=0", "--headless", url) chrome, err := newChromeWithArgs(ChromeExecutable(), args...) if err != nil { return nil, err } defer chrome.kill() return f(chrome) } ================================================ FILE: go.mod ================================================ module github.com/zserge/lorca go 1.16 require golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 ================================================ FILE: go.sum ================================================ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= ================================================ FILE: locate.go ================================================ package lorca import ( "os" "os/exec" "runtime" "strings" ) // ChromeExecutable returns a string which points to the preferred Chrome // executable file. var ChromeExecutable = LocateChrome // LocateChrome returns a path to the Chrome binary, or an empty string if // Chrome installation is not found. func LocateChrome() string { // If env variable "LORCACHROME" specified and it exists if path, ok := os.LookupEnv("LORCACHROME"); ok { if _, err := os.Stat(path); err == nil { return path } } var paths []string switch runtime.GOOS { case "darwin": paths = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", } case "windows": paths = []string{ os.Getenv("LocalAppData") + "/Google/Chrome/Application/chrome.exe", os.Getenv("ProgramFiles") + "/Google/Chrome/Application/chrome.exe", os.Getenv("ProgramFiles(x86)") + "/Google/Chrome/Application/chrome.exe", os.Getenv("LocalAppData") + "/Chromium/Application/chrome.exe", os.Getenv("ProgramFiles") + "/Chromium/Application/chrome.exe", os.Getenv("ProgramFiles(x86)") + "/Chromium/Application/chrome.exe", os.Getenv("ProgramFiles(x86)") + "/Microsoft/Edge/Application/msedge.exe", os.Getenv("ProgramFiles") + "/Microsoft/Edge/Application/msedge.exe", } default: paths = []string{ "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", } } for _, path := range paths { if _, err := os.Stat(path); os.IsNotExist(err) { continue } return path } return "" } // PromptDownload asks user if he wants to download and install Chrome, and // opens a download web page if the user agrees. func PromptDownload() { title := "Chrome not found" text := "No Chrome/Chromium installation was found. Would you like to download and install it now?" // Ask user for confirmation if !messageBox(title, text) { return } // Open download page url := "https://www.google.com/chrome/" switch runtime.GOOS { case "linux": exec.Command("xdg-open", url).Run() case "darwin": exec.Command("open", url).Run() case "windows": r := strings.NewReplacer("&", "^&") exec.Command("cmd", "/c", "start", r.Replace(url)).Run() } } ================================================ FILE: locate_test.go ================================================ package lorca import ( "os/exec" "testing" ) func TestLocate(t *testing.T) { if exe := ChromeExecutable(); exe == "" { t.Fatal() } else { t.Log(exe) b, err := exec.Command(exe, "--version").CombinedOutput() t.Log(string(b)) t.Log(err) } } ================================================ FILE: messagebox.go ================================================ //+build !windows package lorca import ( "fmt" "os/exec" "runtime" "strings" "syscall" ) func messageBox(title, text string) bool { if runtime.GOOS == "linux" { err := exec.Command("zenity", "--question", "--title", title, "--text", text).Run() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { return exitError.Sys().(syscall.WaitStatus).ExitStatus() == 0 } } } else if runtime.GOOS == "darwin" { script := `set T to button returned of ` + `(display dialog "%s" with title "%s" buttons {"No", "Yes"} default button "Yes")` out, err := exec.Command("osascript", "-e", fmt.Sprintf(script, text, title)).Output() if err != nil { if exitError, ok := err.(*exec.ExitError); ok { return exitError.Sys().(syscall.WaitStatus).ExitStatus() == 0 } } return strings.TrimSpace(string(out)) == "Yes" } return false } ================================================ FILE: messagebox_windows.go ================================================ //+build windows package lorca import ( "syscall" "unsafe" ) func messageBox(title, text string) bool { user32 := syscall.NewLazyDLL("user32.dll") messageBoxW := user32.NewProc("MessageBoxW") mbYesNo := 0x00000004 mbIconQuestion := 0x00000020 idYes := 6 ret, _, _ := messageBoxW.Call(0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))), uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), uintptr(uint(mbYesNo|mbIconQuestion))) return int(ret) == idYes } ================================================ FILE: ui.go ================================================ package lorca import ( "encoding/json" "errors" "fmt" "io/ioutil" "os" "reflect" ) // UI interface allows talking to the HTML5 UI from Go. type UI interface { Load(url string) error Bounds() (Bounds, error) SetBounds(Bounds) error Bind(name string, f interface{}) error Eval(js string) Value Done() <-chan struct{} Close() error } type ui struct { chrome *chrome done chan struct{} tmpDir string } var defaultChromeArgs = []string{ "--disable-background-networking", "--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows", "--disable-breakpad", "--disable-client-side-phishing-detection", "--disable-default-apps", "--disable-dev-shm-usage", "--disable-infobars", "--disable-extensions", "--disable-features=site-per-process", "--disable-hang-monitor", "--disable-ipc-flooding-protection", "--disable-popup-blocking", "--disable-prompt-on-repost", "--disable-renderer-backgrounding", "--disable-sync", "--disable-translate", "--disable-windows10-custom-titlebar", "--metrics-recording-only", "--no-first-run", "--no-default-browser-check", "--safebrowsing-disable-auto-update", "--enable-automation", "--password-store=basic", "--use-mock-keychain", "--remote-allow-origins=*", } // New returns a new HTML5 UI for the given URL, user profile directory, window // size and other options passed to the browser engine. If URL is an empty // string - a blank page is displayed. If user profile directory is an empty // string - a temporary directory is created and it will be removed on // ui.Close(). You might want to use "--headless" custom CLI argument to test // your UI code. func New(url, dir string, width, height int, customArgs ...string) (UI, error) { if url == "" { url = "data:text/html," } tmpDir := "" if dir == "" { name, err := ioutil.TempDir("", "lorca") if err != nil { return nil, err } dir, tmpDir = name, name } args := append(defaultChromeArgs, fmt.Sprintf("--app=%s", url)) args = append(args, fmt.Sprintf("--user-data-dir=%s", dir)) args = append(args, fmt.Sprintf("--window-size=%d,%d", width, height)) args = append(args, customArgs...) args = append(args, "--remote-debugging-port=0") chrome, err := newChromeWithArgs(ChromeExecutable(), args...) done := make(chan struct{}) if err != nil { return nil, err } go func() { chrome.cmd.Wait() close(done) }() return &ui{chrome: chrome, done: done, tmpDir: tmpDir}, nil } func (u *ui) Done() <-chan struct{} { return u.done } func (u *ui) Close() error { // ignore err, as the chrome process might be already dead, when user close the window. u.chrome.kill() <-u.done if u.tmpDir != "" { if err := os.RemoveAll(u.tmpDir); err != nil { return err } } return nil } func (u *ui) Load(url string) error { return u.chrome.load(url) } func (u *ui) Bind(name string, f interface{}) error { v := reflect.ValueOf(f) // f must be a function if v.Kind() != reflect.Func { return errors.New("only functions can be bound") } // f must return either value and error or just error if n := v.Type().NumOut(); n > 2 { return errors.New("function may only return a value or a value+error") } return u.chrome.bind(name, func(raw []json.RawMessage) (interface{}, error) { if len(raw) != v.Type().NumIn() { return nil, errors.New("function arguments mismatch") } args := []reflect.Value{} for i := range raw { arg := reflect.New(v.Type().In(i)) if err := json.Unmarshal(raw[i], arg.Interface()); err != nil { return nil, err } args = append(args, arg.Elem()) } errorType := reflect.TypeOf((*error)(nil)).Elem() res := v.Call(args) switch len(res) { case 0: // No results from the function, just return nil return nil, nil case 1: // One result may be a value, or an error if res[0].Type().Implements(errorType) { if res[0].Interface() != nil { return nil, res[0].Interface().(error) } return nil, nil } return res[0].Interface(), nil case 2: // Two results: first one is value, second is error if !res[1].Type().Implements(errorType) { return nil, errors.New("second return value must be an error") } if res[1].Interface() == nil { return res[0].Interface(), nil } return res[0].Interface(), res[1].Interface().(error) default: return nil, errors.New("unexpected number of return values") } }) } func (u *ui) Eval(js string) Value { v, err := u.chrome.eval(js) return value{err: err, raw: v} } func (u *ui) SetBounds(b Bounds) error { return u.chrome.setBounds(b) } func (u *ui) Bounds() (Bounds, error) { return u.chrome.bounds() } ================================================ FILE: ui_test.go ================================================ package lorca import ( "errors" "math/rand" "strconv" "testing" ) func TestEval(t *testing.T) { ui, err := New("", "", 480, 320, "--headless") if err != nil { t.Fatal(err) } defer ui.Close() if n := ui.Eval(`2+3`).Int(); n != 5 { t.Fatal(n) } if s := ui.Eval(`"foo" + "bar"`).String(); s != "foobar" { t.Fatal(s) } if a := ui.Eval(`[1,2,3].map(n => n *2)`).Array(); a[0].Int() != 2 || a[1].Int() != 4 || a[2].Int() != 6 { t.Fatal(a) } // XXX this probably should be unquoted? if err := ui.Eval(`throw "fail"`).Err(); err.Error() != `"fail"` { t.Fatal(err) } } func TestBind(t *testing.T) { ui, err := New("", "", 480, 320, "--headless") if err != nil { t.Fatal(err) } defer ui.Close() if err := ui.Bind("add", func(a, b int) int { return a + b }); err != nil { t.Fatal(err) } if err := ui.Bind("rand", func() int { return rand.Int() }); err != nil { t.Fatal(err) } if err := ui.Bind("strlen", func(s string) int { return len(s) }); err != nil { t.Fatal(err) } if err := ui.Bind("atoi", func(s string) (int, error) { return strconv.Atoi(s) }); err != nil { t.Fatal(err) } if err := ui.Bind("shouldFail", "hello"); err == nil { t.Fail() } if n := ui.Eval(`add(2,3)`); n.Int() != 5 { t.Fatal(n) } if n := ui.Eval(`add(2,3,4)`); n.Err() == nil { t.Fatal(n) } if n := ui.Eval(`add(2)`); n.Err() == nil { t.Fatal(n) } if n := ui.Eval(`add("hello", "world")`); n.Err() == nil { t.Fatal(n) } if n := ui.Eval(`rand()`); n.Err() != nil { t.Fatal(n) } if n := ui.Eval(`rand(100)`); n.Err() == nil { t.Fatal(n) } if n := ui.Eval(`strlen('foo')`); n.Int() != 3 { t.Fatal(n) } if n := ui.Eval(`strlen(123)`); n.Err() == nil { t.Fatal(n) } if n := ui.Eval(`atoi('123')`); n.Int() != 123 { t.Fatal(n) } if n := ui.Eval(`atoi('hello')`); n.Err() == nil { t.Fatal(n) } } func TestFunctionReturnTypes(t *testing.T) { ui, err := New("", "", 480, 320, "--headless") if err != nil { t.Fatal(err) } defer ui.Close() if err := ui.Bind("noResults", func() { return }); err != nil { t.Fatal(err) } if err := ui.Bind("oneNonNilResult", func() interface{} { return 1 }); err != nil { t.Fatal(err) } if err := ui.Bind("oneNilResult", func() interface{} { return nil }); err != nil { t.Fatal(err) } if err := ui.Bind("oneNonNilErrorResult", func() error { return errors.New("error") }); err != nil { t.Fatal(err) } if err := ui.Bind("oneNilErrorResult", func() error { return nil }); err != nil { t.Fatal(err) } if err := ui.Bind("twoResultsNonNilError", func() (interface{}, error) { return nil, errors.New("error") }); err != nil { t.Fatal(err) } if err := ui.Bind("twoResultsNilError", func() (interface{}, error) { return 1, nil }); err != nil { t.Fatal(err) } if err := ui.Bind("twoResultsBothNonNil", func() (interface{}, error) { return 1, errors.New("error") }); err != nil { t.Fatal(err) } if err := ui.Bind("twoResultsBothNil", func() (interface{}, error) { return nil, nil }); err != nil { t.Fatal(err) } if v := ui.Eval(`noResults()`); v.Err() != nil { t.Fatal(v) } if v := ui.Eval(`oneNonNilResult()`); v.Int() != 1 { t.Fatal(v) } if v := ui.Eval(`oneNilResult()`); v.Err() != nil { t.Fatal(v) } if v := ui.Eval(`oneNonNilErrorResult()`); v.Err() == nil { t.Fatal(v) } if v := ui.Eval(`oneNilErrorResult()`); v.Err() != nil { t.Fatal(v) } if v := ui.Eval(`twoResultsNonNilError()`); v.Err() == nil { t.Fatal(v) } if v := ui.Eval(`twoResultsNilError()`); v.Err() != nil || v.Int() != 1 { t.Fatal(v) } if v := ui.Eval(`twoResultsBothNonNil()`); v.Err() == nil { t.Fatal(v) } if v := ui.Eval(`twoResultsBothNil()`); v.Err() != nil { t.Fatal(v) } } ================================================ FILE: value.go ================================================ package lorca import "encoding/json" // Value is a generic type of a JSON value (primitive, object, array) and // optionally an error value. type Value interface { Err() error To(interface{}) error Float() float32 Int() int String() string Bool() bool Object() map[string]Value Array() []Value Bytes() []byte } type value struct { err error raw json.RawMessage } func (v value) Err() error { return v.err } func (v value) Bytes() []byte { return v.raw } func (v value) To(x interface{}) error { return json.Unmarshal(v.raw, x) } func (v value) Float() (f float32) { v.To(&f); return f } func (v value) Int() (i int) { v.To(&i); return i } func (v value) String() (s string) { v.To(&s); return s } func (v value) Bool() (b bool) { v.To(&b); return b } func (v value) Array() (values []Value) { array := []json.RawMessage{} v.To(&array) for _, el := range array { values = append(values, value{raw: el}) } return values } func (v value) Object() (object map[string]Value) { object = map[string]Value{} kv := map[string]json.RawMessage{} v.To(&kv) for k, v := range kv { object[k] = value{raw: v} } return object } ================================================ FILE: value_test.go ================================================ package lorca import ( "bytes" "encoding/json" "errors" "testing" ) var errTest = errors.New("fail") func TestValueError(t *testing.T) { v := value{err: errTest} if v.Err() != errTest { t.Fail() } v = value{raw: json.RawMessage(`"hello"`)} if v.Err() != nil { t.Fail() } } func TestValuePrimitive(t *testing.T) { v := value{raw: json.RawMessage(`42`)} if v.Int() != 42 { t.Fail() } v = value{raw: json.RawMessage(`"hello"`)} if v.Int() != 0 || v.String() != "hello" { t.Fail() } v = value{err: errTest} if v.Int() != 0 || v.String() != "" { t.Fail() } } func TestValueComplex(t *testing.T) { v := value{raw: json.RawMessage(`["foo", 42.3, {"x": 5}]`)} if len(v.Array()) != 3 { t.Fail() } if v.Array()[0].String() != "foo" { t.Fail() } if v.Array()[1].Float() != 42.3 { t.Fail() } if v.Array()[2].Object()["x"].Int() != 5 { t.Fail() } } func TestRawValue(t *testing.T) { var v Value v = value{raw: json.RawMessage(nil)} if v.Bytes() != nil { t.Fail() } v = value{raw: json.RawMessage(`"hello"`)} if !bytes.Equal(v.Bytes(), []byte(`"hello"`)) { t.Fail() } }