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
[](https://github.com/zserge/lorca)
[](https://godoc.org/github.com/zserge/lorca)
[](https://goreportcard.com/report/github.com/zserge/lorca)
<div>
<img align="left" src="https://raw.githubusercontent.com/zserge/lorca/master/lorca.png" alt="Lorca" width="128px" height="128px" />
<br/>
<p>
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.
</p>
<br/>
</div>
## 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()
```
<p align="center"><img src="examples/counter/counter.gif" /></p>
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,<html><body>Hello</body></html>"); 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 <zaitsev.serge@gmail.com>
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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>lorca-example</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleIdentifier</key>
<string>com.zserge.lorca.example</string>
</dict>
</plist>
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,<base64>` 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
================================================
<!doctype html>
<html>
<head>
<title>Counter</title>
<link rel="shortcut icon" href="favicon.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; }
body { height: 100vh; display: flex; align-items: center; justify-content: center; background-color: #f1c40f; font-family: 'Helvetika Neue', Arial, sans-serif; font-size: 28px; }
.counter-container { display: flex; flex-direction: column; align-items: center; }
.counter { text-transform: uppercase; color: #fff; font-weight: bold; font-size: 3rem; }
.btn-row { display: flex; align-items: center; margin: 1rem; }
.btn { cursor: pointer; min-width: 4em; padding: 1em; border-radius: 5px; text-align: center; margin: 0 1rem; box-shadow: 0 6px #8b5e00; color: white; background-color: #E4B702; position: relative; font-weight: bold; }
.btn:hover { box-shadow: 0 4px #8b5e00; top: 2px; }
.btn:active{ box-shadow: 0 1px #8b5e00; top: 5px; }
</style>
</head>
<body onload=start()>
<!-- UI layout -->
<div class="counter-container">
<div class="counter"></div>
<div class="btn-row">
<div class="btn btn-incr">+1</div>
<div class="btn btn-decr">-1</div>
</div>
</div>
<!-- Connect UI actions to Go functions -->
<script>
const counter = document.querySelector('.counter');
const btnIncr = document.querySelector('.btn-incr');
const btnDecr = document.querySelector('.btn-decr');
// We use async/await because Go functions are asynchronous
const render = async () => {
counter.innerText = `Count: ${await window.counterValue()}`;
};
btnIncr.addEventListener('click', async () => {
await counterAdd(1); // Call Go function
render();
});
btnDecr.addEventListener('click', async () => {
await counterAdd(-1); // Call Go function
render();
});
render();
</script>
</body>
</html>
================================================
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(`
<html>
<head><title>Hello</title></head>
<body><h1>Hello, world!</h1></body>
</html>
`), "", 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(`
<html>
<body>
<!-- toggle() and reset() are Go functions wrapped into JS -->
<div class="timer" onclick="toggle()"></div>
<button onclick="reset()">Reset</button>
</body>
</html>
`))
// 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,<html></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()
}
}
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
SYMBOL INDEX (77 symbols across 14 files)
FILE: chrome.go
type result (line 22) | type result struct
type bindingFunc (line 27) | type bindingFunc
type msg (line 30) | type msg struct
type chrome (line 38) | type chrome struct
method findTarget (line 125) | func (c *chrome) findTarget() (string, error) {
method startSession (line 152) | func (c *chrome) startSession(target string) (string, error) {
method getWindowForTarget (line 207) | func (c *chrome) getWindowForTarget(target string) (windowTargetMessag...
method readLoop (line 253) | func (c *chrome) readLoop() {
method send (line 346) | func (c *chrome) send(method string, params h) (json.RawMessage, error) {
method load (line 368) | func (c *chrome) load(url string) error {
method eval (line 373) | func (c *chrome) eval(expr string) (json.RawMessage, error) {
method bind (line 377) | func (c *chrome) bind(name string, f bindingFunc) error {
method setBounds (line 427) | func (c *chrome) setBounds(b Bounds) error {
method bounds (line 439) | func (c *chrome) bounds() (Bounds, error) {
method pdf (line 451) | func (c *chrome) pdf(width, height int) ([]byte, error) {
method png (line 466) | func (c *chrome) png(x, y, width, height int, bg uint32, scale float32...
method kill (line 506) | func (c *chrome) kill() error {
function newChromeWithArgs (line 50) | func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, er...
type WindowState (line 180) | type WindowState
constant WindowStateNormal (line 184) | WindowStateNormal WindowState = "normal"
constant WindowStateMaximized (line 186) | WindowStateMaximized WindowState = "maximized"
constant WindowStateMinimized (line 188) | WindowStateMinimized WindowState = "minimized"
constant WindowStateFullscreen (line 190) | WindowStateFullscreen WindowState = "fullscreen"
type Bounds (line 194) | type Bounds struct
type windowTargetMessage (line 202) | type windowTargetMessage struct
type targetMessageTemplate (line 217) | type targetMessageTemplate struct
type targetMessage (line 235) | type targetMessage struct
function readUntilMatch (line 519) | func readUntilMatch(r io.ReadCloser, re *regexp.Regexp) ([]string, error) {
function contains (line 532) | func contains(arr []string, x string) bool {
FILE: chrome_test.go
function TestChromeEval (line 12) | func TestChromeEval(t *testing.T) {
function TestChromeLoad (line 46) | func TestChromeLoad(t *testing.T) {
function TestChromeBind (line 72) | func TestChromeBind(t *testing.T) {
function TestChromeAsync (line 109) | func TestChromeAsync(t *testing.T) {
FILE: examples/counter/main.go
type counter (line 23) | type counter struct
method Add (line 28) | func (c *counter) Add(n int) {
method Value (line 34) | func (c *counter) Value() int {
function main (line 40) | func main() {
FILE: examples/hello/main.go
function main (line 10) | func main() {
FILE: examples/stopwatch/main.go
function main (line 13) | func main() {
FILE: export.go
constant PageA4Width (line 11) | PageA4Width = 816
constant PageA4Height (line 13) | PageA4Height = 1056
function PDF (line 21) | func PDF(url, script string, width, height int) ([]byte, error) {
function PNG (line 38) | func PNG(url, script string, x, y, width, height int, bg uint32, scale f...
function doHeadless (line 47) | func doHeadless(url string, f func(c *chrome) ([]byte, error)) ([]byte, ...
FILE: locate.go
function LocateChrome (line 16) | func LocateChrome() string {
function PromptDownload (line 70) | func PromptDownload() {
FILE: locate_test.go
function TestLocate (line 8) | func TestLocate(t *testing.T) {
FILE: messagebox.go
function messageBox (line 13) | func messageBox(title, text string) bool {
FILE: messagebox_windows.go
function messageBox (line 10) | func messageBox(title, text string) bool {
FILE: ui.go
type UI (line 13) | type UI interface
type ui (line 23) | type ui struct
method Done (line 95) | func (u *ui) Done() <-chan struct{} {
method Close (line 99) | func (u *ui) Close() error {
method Load (line 111) | func (u *ui) Load(url string) error { return u.chrome.load(url) }
method Bind (line 113) | func (u *ui) Bind(name string, f interface{}) error {
method Eval (line 166) | func (u *ui) Eval(js string) Value {
method SetBounds (line 171) | func (u *ui) SetBounds(b Bounds) error {
method Bounds (line 175) | func (u *ui) Bounds() (Bounds, error) {
function New (line 64) | func New(url, dir string, width, height int, customArgs ...string) (UI, ...
FILE: ui_test.go
function TestEval (line 10) | func TestEval(t *testing.T) {
function TestBind (line 35) | func TestBind(t *testing.T) {
function TestFunctionReturnTypes (line 90) | func TestFunctionReturnTypes(t *testing.T) {
FILE: value.go
type Value (line 7) | type Value interface
type value (line 19) | type value struct
method Err (line 24) | func (v value) Err() error { return v.err }
method Bytes (line 25) | func (v value) Bytes() []byte { return v.raw }
method To (line 26) | func (v value) To(x interface{}) error { return json.Unmarshal(v.raw, ...
method Float (line 27) | func (v value) Float() (f float32) { v.To(&f); return f }
method Int (line 28) | func (v value) Int() (i int) { v.To(&i); return i }
method String (line 29) | func (v value) String() (s string) { v.To(&s); return s }
method Bool (line 30) | func (v value) Bool() (b bool) { v.To(&b); return b }
method Array (line 31) | func (v value) Array() (values []Value) {
method Object (line 39) | func (v value) Object() (object map[string]Value) {
FILE: value_test.go
function TestValueError (line 12) | func TestValueError(t *testing.T) {
function TestValuePrimitive (line 24) | func TestValuePrimitive(t *testing.T) {
function TestValueComplex (line 39) | func TestValueComplex(t *testing.T) {
function TestRawValue (line 55) | func TestRawValue(t *testing.T) {
Condensed preview — 25 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (54K chars).
[
{
"path": ".github/workflows/ci.yml",
"chars": 677,
"preview": "name: CI Pipeline\non: [push, pull_request]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/c"
},
{
"path": ".gitignore",
"chars": 387,
"preview": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\nexample/Example.app\n\n# Test binary, build with `go t"
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2018 Serge Zaitsev\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "README.md",
"chars": 4055,
"preview": "# Lorca\n\n[](https://github.com/"
},
{
"path": "chrome.go",
"chars": 13712,
"preview": "package lorca\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"log\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"sync"
},
{
"path": "chrome_test.go",
"chars": 3440,
"preview": "package lorca\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"testing\"\n)\n\nfunc TestChromeEval(t"
},
{
"path": "examples/counter/build-linux.sh",
"chars": 855,
"preview": "#!/bin/sh\n\nAPP=lorca-example\nAPPDIR=${APP}_1.0.0\n\nmkdir -p $APPDIR/usr/bin\nmkdir -p $APPDIR/usr/share/applications\nmkdir"
},
{
"path": "examples/counter/build-macos.sh",
"chars": 603,
"preview": "#!/bin/sh\n\nAPP=\"Example.app\"\nmkdir -p $APP/Contents/{MacOS,Resources}\ngo build -o $APP/Contents/MacOS/lorca-example\ncat "
},
{
"path": "examples/counter/build-windows.bat",
"chars": 80,
"preview": "@echo off\r\ngo generate\r\ngo build -ldflags \"-H windowsgui\" -o lorca-example.exe\r\n"
},
{
"path": "examples/counter/main.go",
"chars": 1892,
"preview": "package main\n\nimport (\n\t\"embed\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"runtime\"\n\t\"sync\"\n\n\t\"github.com/zse"
},
{
"path": "examples/counter/www/index.html",
"chars": 1862,
"preview": "<!doctype html>\n<html>\n\t<head>\n\t\t<title>Counter</title>\n\t\t<link rel=\"shortcut icon\" href=\"favicon.png\">\n\t\t<style>\n\t\t* { "
},
{
"path": "examples/hello/main.go",
"chars": 408,
"preview": "package main\n\nimport (\n\t\"log\"\n\t\"net/url\"\n\n\t\"github.com/zserge/lorca\"\n)\n\nfunc main() {\n\t// Create UI with basic HTML pass"
},
{
"path": "examples/stopwatch/main.go",
"chars": 1266,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net/url\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/zserge/lorca\"\n)\n\nfunc main() {\n\tui,"
},
{
"path": "export.go",
"chars": 2020,
"preview": "package lorca\n\nimport (\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"os\"\n)\n\nconst (\n\t// PageA4Width is a width of an A4 page in pixels at 96dpi"
},
{
"path": "go.mod",
"chars": 101,
"preview": "module github.com/zserge/lorca\n\ngo 1.16\n\nrequire golang.org/x/net v0.0.0-20200222125558-5a598a2470a0\n"
},
{
"path": "go.sum",
"chars": 504,
"preview": "golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org"
},
{
"path": "locate.go",
"chars": 2585,
"preview": "package lorca\n\nimport (\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strings\"\n)\n\n// ChromeExecutable returns a string which points to t"
},
{
"path": "locate_test.go",
"chars": 256,
"preview": "package lorca\n\nimport (\n\t\"os/exec\"\n\t\"testing\"\n)\n\nfunc TestLocate(t *testing.T) {\n\tif exe := ChromeExecutable(); exe == \""
},
{
"path": "messagebox.go",
"chars": 870,
"preview": "//+build !windows\n\npackage lorca\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"runtime\"\n\t\"strings\"\n\t\"syscall\"\n)\n\nfunc messageBox(title, "
},
{
"path": "messagebox_windows.go",
"chars": 482,
"preview": "//+build windows\n\npackage lorca\n\nimport (\n\t\"syscall\"\n\t\"unsafe\"\n)\n\nfunc messageBox(title, text string) bool {\n\tuser32 := "
},
{
"path": "ui.go",
"chars": 4658,
"preview": "package lorca\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"os\"\n\t\"reflect\"\n)\n\n// UI interface allows talkin"
},
{
"path": "ui_test.go",
"chars": 3711,
"preview": "package lorca\n\nimport (\n\t\"errors\"\n\t\"math/rand\"\n\t\"strconv\"\n\t\"testing\"\n)\n\nfunc TestEval(t *testing.T) {\n\tui, err := New(\"\""
},
{
"path": "value.go",
"chars": 1189,
"preview": "package lorca\n\nimport \"encoding/json\"\n\n// Value is a generic type of a JSON value (primitive, object, array) and\n// opti"
},
{
"path": "value_test.go",
"chars": 1122,
"preview": "package lorca\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"testing\"\n)\n\nvar errTest = errors.New(\"fail\")\n\nfunc TestVal"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the zserge/lorca GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 25 files (46.7 KB), approximately 14.6k tokens, and a symbol index with 77 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.