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

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

17 |
18 |
19 | 20 | 21 | ## Features 22 | 23 | * Pure Go library (no cgo) with a very simple API 24 | * Small application size (normally 5-10MB) 25 | * Best of both worlds - the whole power of HTML/CSS to make your UI look 26 | good, combined with Go performance and ease of development 27 | * Expose Go functions/methods and call them from JavaScript 28 | * Call arbitrary JavaScript code from Go 29 | * Asynchronous flow between UI and main app in both languages (async/await and Goroutines) 30 | * Supports loading web UI from the local web server or via data URL 31 | * Supports testing your app with the UI in the headless mode 32 | * Supports multiple app windows 33 | * Supports packaging and branding (e.g. custom app icons). Packaging for all 34 | three OS can be done on a single machine using GOOS and GOARCH variables. 35 | 36 | Also, limitations by design: 37 | 38 | * Requires Chrome/Chromium >= 70 to be installed. 39 | * No control over the Chrome window yet (e.g. you can't remove border, make it 40 | transparent, control position or size). 41 | * No window menu (tray menus and native OS dialogs are still possible via 42 | 3rd-party libraries) 43 | 44 | If you want to have more control of the browser window - consider using 45 | [webview](https://github.com/zserge/webview) library with a similar API, so 46 | migration would be smooth. 47 | 48 | ## Example 49 | 50 | ```go 51 | ui, _ := lorca.New("", "", 480, 320) 52 | defer ui.Close() 53 | 54 | // Bind Go function to be available in JS. Go function may be long-running and 55 | // blocking - in JS it's represented with a Promise. 56 | ui.Bind("add", func(a, b int) int { return a + b }) 57 | 58 | // Call JS function from Go. Functions may be asynchronous, i.e. return promises 59 | n := ui.Eval(`Math.random()`).Float() 60 | fmt.Println(n) 61 | 62 | // Call JS that calls Go and so on and so on... 63 | m := ui.Eval(`add(2, 3)`).Int() 64 | fmt.Println(m) 65 | 66 | // Wait for the browser window to be closed 67 | <-ui.Done() 68 | ``` 69 | 70 |

71 | 72 | Also, see [examples](examples) for more details about binding functions and packaging binaries. 73 | 74 | ## Hello World 75 | 76 | Here are the steps to run the hello world example. 77 | 78 | ``` 79 | cd examples/counter 80 | go get 81 | go run ./ 82 | ``` 83 | 84 | ## How it works 85 | 86 | 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. 87 | 88 | ## What's in a name? 89 | 90 | > There is kind of a legend, that before his execution Garcia Lorca have seen a 91 | > sunrise over the heads of the soldiers and he said "And yet, the sun rises...". 92 | > Probably it was the beginning of a poem. (J. Brodsky) 93 | 94 | Lorca is an anagram of [Carlo](https://github.com/GoogleChromeLabs/carlo/), a 95 | project with a similar goal for Node.js. 96 | 97 | ## License 98 | 99 | Code is distributed under MIT license, feel free to use it in your proprietary 100 | projects as well. 101 | 102 | -------------------------------------------------------------------------------- /chrome.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os/exec" 12 | "regexp" 13 | "sync" 14 | "sync/atomic" 15 | 16 | "golang.org/x/net/websocket" 17 | ) 18 | 19 | type h = map[string]interface{} 20 | 21 | // Result is a struct for the resulting value of the JS expression or an error. 22 | type result struct { 23 | Value json.RawMessage 24 | Err error 25 | } 26 | 27 | type bindingFunc func(args []json.RawMessage) (interface{}, error) 28 | 29 | // Msg is a struct for incoming messages (results and async events) 30 | type msg struct { 31 | ID int `json:"id"` 32 | Result json.RawMessage `json:"result"` 33 | Error json.RawMessage `json:"error"` 34 | Method string `json:"method"` 35 | Params json.RawMessage `json:"params"` 36 | } 37 | 38 | type chrome struct { 39 | sync.Mutex 40 | cmd *exec.Cmd 41 | ws *websocket.Conn 42 | id int32 43 | target string 44 | session string 45 | window int 46 | pending map[int]chan result 47 | bindings map[string]bindingFunc 48 | } 49 | 50 | func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, error) { 51 | // The first two IDs are used internally during the initialization 52 | c := &chrome{ 53 | id: 2, 54 | pending: map[int]chan result{}, 55 | bindings: map[string]bindingFunc{}, 56 | } 57 | 58 | // Start chrome process 59 | c.cmd = exec.Command(chromeBinary, args...) 60 | pipe, err := c.cmd.StderrPipe() 61 | if err != nil { 62 | return nil, err 63 | } 64 | if err := c.cmd.Start(); err != nil { 65 | return nil, err 66 | } 67 | 68 | // Wait for websocket address to be printed to stderr 69 | re := regexp.MustCompile(`^DevTools listening on (ws://.*?)\r?\n$`) 70 | m, err := readUntilMatch(pipe, re) 71 | if err != nil { 72 | c.kill() 73 | return nil, err 74 | } 75 | wsURL := m[1] 76 | 77 | // Open a websocket 78 | c.ws, err = websocket.Dial(wsURL, "", "http://127.0.0.1") 79 | if err != nil { 80 | c.kill() 81 | return nil, err 82 | } 83 | 84 | // Find target and initialize session 85 | c.target, err = c.findTarget() 86 | if err != nil { 87 | c.kill() 88 | return nil, err 89 | } 90 | 91 | c.session, err = c.startSession(c.target) 92 | if err != nil { 93 | c.kill() 94 | return nil, err 95 | } 96 | go c.readLoop() 97 | for method, args := range map[string]h{ 98 | "Page.enable": nil, 99 | "Target.setAutoAttach": {"autoAttach": true, "waitForDebuggerOnStart": false}, 100 | "Network.enable": nil, 101 | "Runtime.enable": nil, 102 | "Security.enable": nil, 103 | "Performance.enable": nil, 104 | "Log.enable": nil, 105 | } { 106 | if _, err := c.send(method, args); err != nil { 107 | c.kill() 108 | c.cmd.Wait() 109 | return nil, err 110 | } 111 | } 112 | 113 | if !contains(args, "--headless") { 114 | win, err := c.getWindowForTarget(c.target) 115 | if err != nil { 116 | c.kill() 117 | return nil, err 118 | } 119 | c.window = win.WindowID 120 | } 121 | 122 | return c, nil 123 | } 124 | 125 | func (c *chrome) findTarget() (string, error) { 126 | err := websocket.JSON.Send(c.ws, h{ 127 | "id": 0, "method": "Target.setDiscoverTargets", "params": h{"discover": true}, 128 | }) 129 | if err != nil { 130 | return "", err 131 | } 132 | for { 133 | m := msg{} 134 | if err = websocket.JSON.Receive(c.ws, &m); err != nil { 135 | return "", err 136 | } else if m.Method == "Target.targetCreated" { 137 | target := struct { 138 | TargetInfo struct { 139 | Type string `json:"type"` 140 | ID string `json:"targetId"` 141 | } `json:"targetInfo"` 142 | }{} 143 | if err := json.Unmarshal(m.Params, &target); err != nil { 144 | return "", err 145 | } else if target.TargetInfo.Type == "page" { 146 | return target.TargetInfo.ID, nil 147 | } 148 | } 149 | } 150 | } 151 | 152 | func (c *chrome) startSession(target string) (string, error) { 153 | err := websocket.JSON.Send(c.ws, h{ 154 | "id": 1, "method": "Target.attachToTarget", "params": h{"targetId": target}, 155 | }) 156 | if err != nil { 157 | return "", err 158 | } 159 | for { 160 | m := msg{} 161 | if err = websocket.JSON.Receive(c.ws, &m); err != nil { 162 | return "", err 163 | } else if m.ID == 1 { 164 | if m.Error != nil { 165 | return "", errors.New("Target error: " + string(m.Error)) 166 | } 167 | session := struct { 168 | ID string `json:"sessionId"` 169 | }{} 170 | if err := json.Unmarshal(m.Result, &session); err != nil { 171 | return "", err 172 | } 173 | return session.ID, nil 174 | } 175 | } 176 | } 177 | 178 | // WindowState defines the state of the Chrome window, possible values are 179 | // "normal", "maximized", "minimized" and "fullscreen". 180 | type WindowState string 181 | 182 | const ( 183 | // WindowStateNormal defines a normal state of the browser window 184 | WindowStateNormal WindowState = "normal" 185 | // WindowStateMaximized defines a maximized state of the browser window 186 | WindowStateMaximized WindowState = "maximized" 187 | // WindowStateMinimized defines a minimized state of the browser window 188 | WindowStateMinimized WindowState = "minimized" 189 | // WindowStateFullscreen defines a fullscreen state of the browser window 190 | WindowStateFullscreen WindowState = "fullscreen" 191 | ) 192 | 193 | // Bounds defines settable window properties. 194 | type Bounds struct { 195 | Left int `json:"left"` 196 | Top int `json:"top"` 197 | Width int `json:"width"` 198 | Height int `json:"height"` 199 | WindowState WindowState `json:"windowState"` 200 | } 201 | 202 | type windowTargetMessage struct { 203 | WindowID int `json:"windowId"` 204 | Bounds Bounds `json:"bounds"` 205 | } 206 | 207 | func (c *chrome) getWindowForTarget(target string) (windowTargetMessage, error) { 208 | var m windowTargetMessage 209 | msg, err := c.send("Browser.getWindowForTarget", h{"targetId": target}) 210 | if err != nil { 211 | return m, err 212 | } 213 | err = json.Unmarshal(msg, &m) 214 | return m, err 215 | } 216 | 217 | type targetMessageTemplate struct { 218 | ID int `json:"id"` 219 | Method string `json:"method"` 220 | Params struct { 221 | Name string `json:"name"` 222 | Payload string `json:"payload"` 223 | ID int `json:"executionContextId"` 224 | Args []struct { 225 | Type string `json:"type"` 226 | Value interface{} `json:"value"` 227 | } `json:"args"` 228 | } `json:"params"` 229 | Error struct { 230 | Message string `json:"message"` 231 | } `json:"error"` 232 | Result json.RawMessage `json:"result"` 233 | } 234 | 235 | type targetMessage struct { 236 | targetMessageTemplate 237 | Result struct { 238 | Result struct { 239 | Type string `json:"type"` 240 | Subtype string `json:"subtype"` 241 | Description string `json:"description"` 242 | Value json.RawMessage `json:"value"` 243 | ObjectID string `json:"objectId"` 244 | } `json:"result"` 245 | Exception struct { 246 | Exception struct { 247 | Value json.RawMessage `json:"value"` 248 | } `json:"exception"` 249 | } `json:"exceptionDetails"` 250 | } `json:"result"` 251 | } 252 | 253 | func (c *chrome) readLoop() { 254 | for { 255 | m := msg{} 256 | if err := websocket.JSON.Receive(c.ws, &m); err != nil { 257 | return 258 | } 259 | 260 | if m.Method == "Target.receivedMessageFromTarget" { 261 | params := struct { 262 | SessionID string `json:"sessionId"` 263 | Message string `json:"message"` 264 | }{} 265 | json.Unmarshal(m.Params, ¶ms) 266 | if params.SessionID != c.session { 267 | continue 268 | } 269 | res := targetMessage{} 270 | json.Unmarshal([]byte(params.Message), &res) 271 | 272 | if res.ID == 0 && res.Method == "Runtime.consoleAPICalled" || res.Method == "Runtime.exceptionThrown" { 273 | log.Println(params.Message) 274 | } else if res.ID == 0 && res.Method == "Runtime.bindingCalled" { 275 | payload := struct { 276 | Name string `json:"name"` 277 | Seq int `json:"seq"` 278 | Args []json.RawMessage `json:"args"` 279 | }{} 280 | json.Unmarshal([]byte(res.Params.Payload), &payload) 281 | 282 | c.Lock() 283 | binding, ok := c.bindings[res.Params.Name] 284 | c.Unlock() 285 | if ok { 286 | jsString := func(v interface{}) string { b, _ := json.Marshal(v); return string(b) } 287 | go func() { 288 | result, error := "", `""` 289 | if r, err := binding(payload.Args); err != nil { 290 | error = jsString(err.Error()) 291 | } else if b, err := json.Marshal(r); err != nil { 292 | error = jsString(err.Error()) 293 | } else { 294 | result = string(b) 295 | } 296 | expr := fmt.Sprintf(` 297 | if (%[4]s) { 298 | window['%[1]s']['errors'].get(%[2]d)(%[4]s); 299 | } else { 300 | window['%[1]s']['callbacks'].get(%[2]d)(%[3]s); 301 | } 302 | window['%[1]s']['callbacks'].delete(%[2]d); 303 | window['%[1]s']['errors'].delete(%[2]d); 304 | `, payload.Name, payload.Seq, result, error) 305 | c.send("Runtime.evaluate", h{"expression": expr, "contextId": res.Params.ID}) 306 | }() 307 | } 308 | continue 309 | } 310 | 311 | c.Lock() 312 | resc, ok := c.pending[res.ID] 313 | delete(c.pending, res.ID) 314 | c.Unlock() 315 | 316 | if !ok { 317 | continue 318 | } 319 | 320 | if res.Error.Message != "" { 321 | resc <- result{Err: errors.New(res.Error.Message)} 322 | } else if res.Result.Exception.Exception.Value != nil { 323 | resc <- result{Err: errors.New(string(res.Result.Exception.Exception.Value))} 324 | } else if res.Result.Result.Type == "object" && res.Result.Result.Subtype == "error" { 325 | resc <- result{Err: errors.New(res.Result.Result.Description)} 326 | } else if res.Result.Result.Type != "" { 327 | resc <- result{Value: res.Result.Result.Value} 328 | } else { 329 | res := targetMessageTemplate{} 330 | json.Unmarshal([]byte(params.Message), &res) 331 | resc <- result{Value: res.Result} 332 | } 333 | } else if m.Method == "Target.targetDestroyed" { 334 | params := struct { 335 | TargetID string `json:"targetId"` 336 | }{} 337 | json.Unmarshal(m.Params, ¶ms) 338 | if params.TargetID == c.target { 339 | c.kill() 340 | return 341 | } 342 | } 343 | } 344 | } 345 | 346 | func (c *chrome) send(method string, params h) (json.RawMessage, error) { 347 | id := atomic.AddInt32(&c.id, 1) 348 | b, err := json.Marshal(h{"id": int(id), "method": method, "params": params}) 349 | if err != nil { 350 | return nil, err 351 | } 352 | resc := make(chan result) 353 | c.Lock() 354 | c.pending[int(id)] = resc 355 | c.Unlock() 356 | 357 | if err := websocket.JSON.Send(c.ws, h{ 358 | "id": int(id), 359 | "method": "Target.sendMessageToTarget", 360 | "params": h{"message": string(b), "sessionId": c.session}, 361 | }); err != nil { 362 | return nil, err 363 | } 364 | res := <-resc 365 | return res.Value, res.Err 366 | } 367 | 368 | func (c *chrome) load(url string) error { 369 | _, err := c.send("Page.navigate", h{"url": url}) 370 | return err 371 | } 372 | 373 | func (c *chrome) eval(expr string) (json.RawMessage, error) { 374 | return c.send("Runtime.evaluate", h{"expression": expr, "awaitPromise": true, "returnByValue": true}) 375 | } 376 | 377 | func (c *chrome) bind(name string, f bindingFunc) error { 378 | c.Lock() 379 | // check if binding already exists 380 | _, exists := c.bindings[name] 381 | 382 | c.bindings[name] = f 383 | c.Unlock() 384 | 385 | if exists { 386 | // Just replace callback and return, as the binding was already added to js 387 | // and adding it again would break it. 388 | return nil 389 | } 390 | 391 | if _, err := c.send("Runtime.addBinding", h{"name": name}); err != nil { 392 | return err 393 | } 394 | script := fmt.Sprintf(`(() => { 395 | const bindingName = '%s'; 396 | const binding = window[bindingName]; 397 | window[bindingName] = async (...args) => { 398 | const me = window[bindingName]; 399 | let errors = me['errors']; 400 | let callbacks = me['callbacks']; 401 | if (!callbacks) { 402 | callbacks = new Map(); 403 | me['callbacks'] = callbacks; 404 | } 405 | if (!errors) { 406 | errors = new Map(); 407 | me['errors'] = errors; 408 | } 409 | const seq = (me['lastSeq'] || 0) + 1; 410 | me['lastSeq'] = seq; 411 | const promise = new Promise((resolve, reject) => { 412 | callbacks.set(seq, resolve); 413 | errors.set(seq, reject); 414 | }); 415 | binding(JSON.stringify({name: bindingName, seq, args})); 416 | return promise; 417 | }})(); 418 | `, name) 419 | _, err := c.send("Page.addScriptToEvaluateOnNewDocument", h{"source": script}) 420 | if err != nil { 421 | return err 422 | } 423 | _, err = c.eval(script) 424 | return err 425 | } 426 | 427 | func (c *chrome) setBounds(b Bounds) error { 428 | if b.WindowState == "" { 429 | b.WindowState = WindowStateNormal 430 | } 431 | param := h{"windowId": c.window, "bounds": b} 432 | if b.WindowState != WindowStateNormal { 433 | param["bounds"] = h{"windowState": b.WindowState} 434 | } 435 | _, err := c.send("Browser.setWindowBounds", param) 436 | return err 437 | } 438 | 439 | func (c *chrome) bounds() (Bounds, error) { 440 | result, err := c.send("Browser.getWindowBounds", h{"windowId": c.window}) 441 | if err != nil { 442 | return Bounds{}, err 443 | } 444 | bounds := struct { 445 | Bounds Bounds `json:"bounds"` 446 | }{} 447 | err = json.Unmarshal(result, &bounds) 448 | return bounds.Bounds, err 449 | } 450 | 451 | func (c *chrome) pdf(width, height int) ([]byte, error) { 452 | result, err := c.send("Page.printToPDF", h{ 453 | "paperWidth": float32(width) / 96, 454 | "paperHeight": float32(height) / 96, 455 | }) 456 | if err != nil { 457 | return nil, err 458 | } 459 | pdf := struct { 460 | Data []byte `json:"data"` 461 | }{} 462 | err = json.Unmarshal(result, &pdf) 463 | return pdf.Data, err 464 | } 465 | 466 | func (c *chrome) png(x, y, width, height int, bg uint32, scale float32) ([]byte, error) { 467 | if x == 0 && y == 0 && width == 0 && height == 0 { 468 | // By default either use SVG size if it's an SVG, or use A4 page size 469 | 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]`) 470 | if err != nil { 471 | return nil, err 472 | } 473 | rect := make([]int, 4) 474 | if err := json.Unmarshal(bounds, &rect); err != nil { 475 | return nil, err 476 | } 477 | x, y, width, height = rect[0], rect[1], rect[2], rect[3] 478 | } 479 | 480 | _, err := c.send("Emulation.setDefaultBackgroundColorOverride", h{ 481 | "color": h{ 482 | "r": (bg >> 16) & 0xff, 483 | "g": (bg >> 8) & 0xff, 484 | "b": bg & 0xff, 485 | "a": (bg >> 24) & 0xff, 486 | }, 487 | }) 488 | if err != nil { 489 | return nil, err 490 | } 491 | result, err := c.send("Page.captureScreenshot", h{ 492 | "clip": h{ 493 | "x": x, "y": y, "width": width, "height": height, "scale": scale, 494 | }, 495 | }) 496 | if err != nil { 497 | return nil, err 498 | } 499 | pdf := struct { 500 | Data []byte `json:"data"` 501 | }{} 502 | err = json.Unmarshal(result, &pdf) 503 | return pdf.Data, err 504 | } 505 | 506 | func (c *chrome) kill() error { 507 | if c.ws != nil { 508 | if err := c.ws.Close(); err != nil { 509 | return err 510 | } 511 | } 512 | // TODO: cancel all pending requests 513 | if state := c.cmd.ProcessState; state == nil || !state.Exited() { 514 | return c.cmd.Process.Kill() 515 | } 516 | return nil 517 | } 518 | 519 | func readUntilMatch(r io.ReadCloser, re *regexp.Regexp) ([]string, error) { 520 | br := bufio.NewReader(r) 521 | for { 522 | if line, err := br.ReadString('\n'); err != nil { 523 | r.Close() 524 | return nil, err 525 | } else if m := re.FindStringSubmatch(line); m != nil { 526 | go io.Copy(ioutil.Discard, br) 527 | return m, nil 528 | } 529 | } 530 | } 531 | 532 | func contains(arr []string, x string) bool { 533 | for _, n := range arr { 534 | if x == n { 535 | return true 536 | } 537 | } 538 | return false 539 | } 540 | -------------------------------------------------------------------------------- /chrome_test.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | "sync" 8 | "sync/atomic" 9 | "testing" 10 | ) 11 | 12 | func TestChromeEval(t *testing.T) { 13 | c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | defer c.kill() 18 | 19 | for _, test := range []struct { 20 | Expr string 21 | Result string 22 | Error string 23 | }{ 24 | {Expr: ``, Result: ``}, 25 | {Expr: `42`, Result: `42`}, 26 | {Expr: `2+3`, Result: `5`}, 27 | {Expr: `(() => ({x: 5, y: 7}))()`, Result: `{"x":5,"y":7}`}, 28 | {Expr: `(() => ([1,'foo',false]))()`, Result: `[1,"foo",false]`}, 29 | {Expr: `((a, b) => a*b)(3, 7)`, Result: `21`}, 30 | {Expr: `Promise.resolve(42)`, Result: `42`}, 31 | {Expr: `Promise.reject('foo')`, Error: `"foo"`}, 32 | {Expr: `throw "bar"`, Error: `"bar"`}, 33 | {Expr: `2+`, Error: `SyntaxError: Unexpected end of input`}, 34 | } { 35 | result, err := c.eval(test.Expr) 36 | if err != nil { 37 | if err.Error() != test.Error { 38 | t.Fatal(test.Expr, err, test.Error) 39 | } 40 | } else if string(result) != test.Result { 41 | t.Fatal(test.Expr, string(result), test.Result) 42 | } 43 | } 44 | } 45 | 46 | func TestChromeLoad(t *testing.T) { 47 | c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | defer c.kill() 52 | if err := c.load("data:text/html,Hello"); err != nil { 53 | t.Fatal(err) 54 | } 55 | for i := 0; i < 10; i++ { 56 | url, err := c.eval(`window.location.href`) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | if strings.HasPrefix(string(url), `"data:text/html,`) { 61 | break 62 | } 63 | } 64 | if res, err := c.eval(`document.body ? document.body.innerText : 65 | new Promise(res => window.onload = () => res(document.body.innerText))`); err != nil { 66 | t.Fatal(err) 67 | } else if string(res) != `"Hello"` { 68 | t.Fatal(res) 69 | } 70 | } 71 | 72 | func TestChromeBind(t *testing.T) { 73 | c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | defer c.kill() 78 | 79 | if err := c.bind("add", func(args []json.RawMessage) (interface{}, error) { 80 | a, b := 0, 0 81 | if len(args) != 2 { 82 | return nil, errors.New("2 arguments expected") 83 | } 84 | if err := json.Unmarshal(args[0], &a); err != nil { 85 | return nil, err 86 | } 87 | if err := json.Unmarshal(args[1], &b); err != nil { 88 | return nil, err 89 | } 90 | return a + b, nil 91 | }); err != nil { 92 | t.Fatal(err) 93 | } 94 | 95 | if res, err := c.eval(`window.add(2, 3)`); err != nil { 96 | t.Fatal(err) 97 | } else if string(res) != `5` { 98 | t.Fatal(string(res)) 99 | } 100 | 101 | if res, err := c.eval(`window.add("foo", "bar")`); err == nil { 102 | t.Fatal(string(res), err) 103 | } 104 | if res, err := c.eval(`window.add(1, 2, 3)`); err == nil { 105 | t.Fatal(res, err) 106 | } 107 | } 108 | 109 | func TestChromeAsync(t *testing.T) { 110 | c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0") 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | defer c.kill() 115 | 116 | if err := c.bind("len", func(args []json.RawMessage) (interface{}, error) { 117 | return len(args[0]), nil 118 | }); err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | wg := &sync.WaitGroup{} 123 | n := 10 124 | failed := int32(0) 125 | wg.Add(n) 126 | for i := 0; i < n; i++ { 127 | go func(i int) { 128 | defer wg.Done() 129 | v, err := c.eval("len('hello')") 130 | if string(v) != `7` { 131 | atomic.StoreInt32(&failed, 1) 132 | } else if err != nil { 133 | atomic.StoreInt32(&failed, 2) 134 | } 135 | }(i) 136 | } 137 | wg.Wait() 138 | 139 | if status := atomic.LoadInt32(&failed); status != 0 { 140 | t.Fatal() 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /examples/counter/build-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APP=lorca-example 4 | APPDIR=${APP}_1.0.0 5 | 6 | mkdir -p $APPDIR/usr/bin 7 | mkdir -p $APPDIR/usr/share/applications 8 | mkdir -p $APPDIR/usr/share/icons/hicolor/1024x1024/apps 9 | mkdir -p $APPDIR/usr/share/icons/hicolor/256x256/apps 10 | mkdir -p $APPDIR/DEBIAN 11 | 12 | go build -o $APPDIR/usr/bin/$APP 13 | 14 | cp icons/icon.png $APPDIR/usr/share/icons/hicolor/1024x1024/apps/${APP}.png 15 | cp icons/icon.png $APPDIR/usr/share/icons/hicolor/256x256/apps/${APP}.png 16 | 17 | cat > $APPDIR/usr/share/applications/${APP}.desktop << EOF 18 | [Desktop Entry] 19 | Version=1.0 20 | Type=Application 21 | Name=$APP 22 | Exec=$APP 23 | Icon=$APP 24 | Terminal=false 25 | StartupWMClass=Lorca 26 | EOF 27 | 28 | cat > $APPDIR/DEBIAN/control << EOF 29 | Package: ${APP} 30 | Version: 1.0-0 31 | Section: base 32 | Priority: optional 33 | Architecture: amd64 34 | Maintainer: Serge Zaitsev 35 | Description: Example for Lorca GUI toolkit 36 | EOF 37 | 38 | dpkg-deb --build $APPDIR 39 | -------------------------------------------------------------------------------- /examples/counter/build-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APP="Example.app" 4 | mkdir -p $APP/Contents/{MacOS,Resources} 5 | go build -o $APP/Contents/MacOS/lorca-example 6 | cat > $APP/Contents/Info.plist << EOF 7 | 8 | 9 | 10 | 11 | CFBundleExecutable 12 | lorca-example 13 | CFBundleIconFile 14 | icon.icns 15 | CFBundleIdentifier 16 | com.zserge.lorca.example 17 | 18 | 19 | EOF 20 | cp icons/icon.icns $APP/Contents/Resources/icon.icns 21 | find $APP 22 | -------------------------------------------------------------------------------- /examples/counter/build-windows.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | go generate 3 | go build -ldflags "-H windowsgui" -o lorca-example.exe 4 | -------------------------------------------------------------------------------- /examples/counter/counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/examples/counter/counter.gif -------------------------------------------------------------------------------- /examples/counter/icons/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/examples/counter/icons/icon-256.png -------------------------------------------------------------------------------- /examples/counter/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/examples/counter/icons/icon.icns -------------------------------------------------------------------------------- /examples/counter/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/examples/counter/icons/icon.png -------------------------------------------------------------------------------- /examples/counter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log" 7 | "net" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "runtime" 12 | "sync" 13 | 14 | "github.com/zserge/lorca" 15 | ) 16 | 17 | //go:embed www 18 | var fs embed.FS 19 | 20 | // Go types that are bound to the UI must be thread-safe, because each binding 21 | // is executed in its own goroutine. In this simple case we may use atomic 22 | // operations, but for more complex cases one should use proper synchronization. 23 | type counter struct { 24 | sync.Mutex 25 | count int 26 | } 27 | 28 | func (c *counter) Add(n int) { 29 | c.Lock() 30 | defer c.Unlock() 31 | c.count = c.count + n 32 | } 33 | 34 | func (c *counter) Value() int { 35 | c.Lock() 36 | defer c.Unlock() 37 | return c.count 38 | } 39 | 40 | func main() { 41 | args := []string{} 42 | if runtime.GOOS == "linux" { 43 | args = append(args, "--class=Lorca") 44 | } 45 | ui, err := lorca.New("", "", 480, 320, args...) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | defer ui.Close() 50 | 51 | // A simple way to know when UI is ready (uses body.onload event in JS) 52 | ui.Bind("start", func() { 53 | log.Println("UI is ready") 54 | }) 55 | 56 | // Create and bind Go object to the UI 57 | c := &counter{} 58 | ui.Bind("counterAdd", c.Add) 59 | ui.Bind("counterValue", c.Value) 60 | 61 | // Load HTML. 62 | // You may also use `data:text/html,` approach to load initial HTML, 63 | // e.g: ui.Load("data:text/html," + url.PathEscape(html)) 64 | 65 | ln, err := net.Listen("tcp", "127.0.0.1:0") 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | defer ln.Close() 70 | go http.Serve(ln, http.FileServer(http.FS(fs))) 71 | ui.Load(fmt.Sprintf("http://%s/www", ln.Addr())) 72 | 73 | // You may use console.log to debug your JS code, it will be printed via 74 | // log.Println(). Also exceptions are printed in a similar manner. 75 | ui.Eval(` 76 | console.log("Hello, world!"); 77 | console.log('Multiple values:', [1, false, {"x":5}]); 78 | `) 79 | 80 | // Wait until the interrupt signal arrives or browser window is closed 81 | sigc := make(chan os.Signal) 82 | signal.Notify(sigc, os.Interrupt) 83 | select { 84 | case <-sigc: 85 | case <-ui.Done(): 86 | } 87 | 88 | log.Println("exiting...") 89 | } 90 | -------------------------------------------------------------------------------- /examples/counter/www/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/examples/counter/www/favicon.png -------------------------------------------------------------------------------- /examples/counter/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Counter 5 | 6 | 16 | 17 | 18 | 19 |
20 |
21 |
22 |
+1
23 |
-1
24 |
25 |
26 | 27 | 28 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /examples/hello/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/url" 6 | 7 | "github.com/zserge/lorca" 8 | ) 9 | 10 | func main() { 11 | // Create UI with basic HTML passed via data URI 12 | ui, err := lorca.New("data:text/html,"+url.PathEscape(` 13 | 14 | Hello 15 |

Hello, world!

16 | 17 | `), "", 480, 320) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | defer ui.Close() 22 | // Wait until UI window is closed 23 | <-ui.Done() 24 | } 25 | -------------------------------------------------------------------------------- /examples/stopwatch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/zserge/lorca" 11 | ) 12 | 13 | func main() { 14 | ui, err := lorca.New("", "", 480, 320) 15 | if err != nil { 16 | log.Fatal(err) 17 | } 18 | defer ui.Close() 19 | 20 | // Data model: number of ticks 21 | ticks := uint32(0) 22 | // Channel to connect UI events with the background ticking goroutine 23 | togglec := make(chan bool) 24 | // Bind Go functions to JS 25 | ui.Bind("toggle", func() { togglec <- true }) 26 | ui.Bind("reset", func() { 27 | atomic.StoreUint32(&ticks, 0) 28 | ui.Eval(`document.querySelector('.timer').innerText = '0'`) 29 | }) 30 | 31 | // Load HTML after Go functions are bound to JS 32 | ui.Load("data:text/html," + url.PathEscape(` 33 | 34 | 35 | 36 |
37 | 38 | 39 | 40 | `)) 41 | 42 | // Start ticker goroutine 43 | go func() { 44 | t := time.NewTicker(100 * time.Millisecond) 45 | for { 46 | select { 47 | case <-t.C: // Every 100ms increate number of ticks and update UI 48 | ui.Eval(fmt.Sprintf(`document.querySelector('.timer').innerText = 0.1*%d`, 49 | atomic.AddUint32(&ticks, 1))) 50 | case <-togglec: // If paused - wait for another toggle event to unpause 51 | <-togglec 52 | } 53 | } 54 | }() 55 | <-ui.Done() 56 | } 57 | -------------------------------------------------------------------------------- /export.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | const ( 10 | // PageA4Width is a width of an A4 page in pixels at 96dpi 11 | PageA4Width = 816 12 | // PageA4Height is a height of an A4 page in pixels at 96dpi 13 | PageA4Height = 1056 14 | ) 15 | 16 | // PDF converts a given URL (may be a local file) to a PDF file. Script is 17 | // evaluated before the page is printed to PDF, you may modify the contents of 18 | // the page there of wait until the page is fully rendered. Width and height 19 | // are page bounds in pixels. PDF by default uses 96dpi density. For A4 page 20 | // you may use PageA4Width and PageA4Height constants. 21 | func PDF(url, script string, width, height int) ([]byte, error) { 22 | return doHeadless(url, func(c *chrome) ([]byte, error) { 23 | if _, err := c.eval(script); err != nil { 24 | return nil, err 25 | } 26 | return c.pdf(width, height) 27 | }) 28 | } 29 | 30 | // PNG converts a given URL (may be a local file) to a PNG image. Script is 31 | // evaluated before the "screenshot" is taken, so you can modify the contents 32 | // of a URL there. Image bounds are provides in pixels. Background is in ARGB 33 | // format, the default value of zero keeps the background transparent. Scale 34 | // allows zooming the page in and out. 35 | // 36 | // This function is most convenient to convert SVG to PNG of different sizes, 37 | // for example when preparing Lorca app icons. 38 | func PNG(url, script string, x, y, width, height int, bg uint32, scale float32) ([]byte, error) { 39 | return doHeadless(url, func(c *chrome) ([]byte, error) { 40 | if _, err := c.eval(script); err != nil { 41 | return nil, err 42 | } 43 | return c.png(x, y, width, height, bg, scale) 44 | }) 45 | } 46 | 47 | func doHeadless(url string, f func(c *chrome) ([]byte, error)) ([]byte, error) { 48 | dir, err := ioutil.TempDir("", "lorca") 49 | if err != nil { 50 | return nil, err 51 | } 52 | defer os.RemoveAll(dir) 53 | args := append(defaultChromeArgs, fmt.Sprintf("--user-data-dir=%s", dir), "--remote-debugging-port=0", "--headless", url) 54 | chrome, err := newChromeWithArgs(ChromeExecutable(), args...) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer chrome.kill() 59 | return f(chrome) 60 | } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zserge/lorca 2 | 3 | go 1.16 4 | 5 | require golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 2 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs= 3 | golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 4 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 5 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 6 | -------------------------------------------------------------------------------- /locate.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "runtime" 7 | "strings" 8 | ) 9 | 10 | // ChromeExecutable returns a string which points to the preferred Chrome 11 | // executable file. 12 | var ChromeExecutable = LocateChrome 13 | 14 | // LocateChrome returns a path to the Chrome binary, or an empty string if 15 | // Chrome installation is not found. 16 | func LocateChrome() string { 17 | 18 | // If env variable "LORCACHROME" specified and it exists 19 | if path, ok := os.LookupEnv("LORCACHROME"); ok { 20 | if _, err := os.Stat(path); err == nil { 21 | return path 22 | } 23 | } 24 | 25 | var paths []string 26 | switch runtime.GOOS { 27 | case "darwin": 28 | paths = []string{ 29 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", 30 | "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", 31 | "/Applications/Chromium.app/Contents/MacOS/Chromium", 32 | "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", 33 | "/usr/bin/google-chrome-stable", 34 | "/usr/bin/google-chrome", 35 | "/usr/bin/chromium", 36 | "/usr/bin/chromium-browser", 37 | } 38 | case "windows": 39 | paths = []string{ 40 | os.Getenv("LocalAppData") + "/Google/Chrome/Application/chrome.exe", 41 | os.Getenv("ProgramFiles") + "/Google/Chrome/Application/chrome.exe", 42 | os.Getenv("ProgramFiles(x86)") + "/Google/Chrome/Application/chrome.exe", 43 | os.Getenv("LocalAppData") + "/Chromium/Application/chrome.exe", 44 | os.Getenv("ProgramFiles") + "/Chromium/Application/chrome.exe", 45 | os.Getenv("ProgramFiles(x86)") + "/Chromium/Application/chrome.exe", 46 | os.Getenv("ProgramFiles(x86)") + "/Microsoft/Edge/Application/msedge.exe", 47 | os.Getenv("ProgramFiles") + "/Microsoft/Edge/Application/msedge.exe", 48 | } 49 | default: 50 | paths = []string{ 51 | "/usr/bin/google-chrome-stable", 52 | "/usr/bin/google-chrome", 53 | "/usr/bin/chromium", 54 | "/usr/bin/chromium-browser", 55 | "/snap/bin/chromium", 56 | } 57 | } 58 | 59 | for _, path := range paths { 60 | if _, err := os.Stat(path); os.IsNotExist(err) { 61 | continue 62 | } 63 | return path 64 | } 65 | return "" 66 | } 67 | 68 | // PromptDownload asks user if he wants to download and install Chrome, and 69 | // opens a download web page if the user agrees. 70 | func PromptDownload() { 71 | title := "Chrome not found" 72 | text := "No Chrome/Chromium installation was found. Would you like to download and install it now?" 73 | 74 | // Ask user for confirmation 75 | if !messageBox(title, text) { 76 | return 77 | } 78 | 79 | // Open download page 80 | url := "https://www.google.com/chrome/" 81 | switch runtime.GOOS { 82 | case "linux": 83 | exec.Command("xdg-open", url).Run() 84 | case "darwin": 85 | exec.Command("open", url).Run() 86 | case "windows": 87 | r := strings.NewReplacer("&", "^&") 88 | exec.Command("cmd", "/c", "start", r.Replace(url)).Run() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /locate_test.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "os/exec" 5 | "testing" 6 | ) 7 | 8 | func TestLocate(t *testing.T) { 9 | if exe := ChromeExecutable(); exe == "" { 10 | t.Fatal() 11 | } else { 12 | t.Log(exe) 13 | b, err := exec.Command(exe, "--version").CombinedOutput() 14 | t.Log(string(b)) 15 | t.Log(err) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lorca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/lorca/36a77caf0fc720b159df1def168acc7e408fdc76/lorca.png -------------------------------------------------------------------------------- /messagebox.go: -------------------------------------------------------------------------------- 1 | //+build !windows 2 | 3 | package lorca 4 | 5 | import ( 6 | "fmt" 7 | "os/exec" 8 | "runtime" 9 | "strings" 10 | "syscall" 11 | ) 12 | 13 | func messageBox(title, text string) bool { 14 | if runtime.GOOS == "linux" { 15 | err := exec.Command("zenity", "--question", "--title", title, "--text", text).Run() 16 | if err != nil { 17 | if exitError, ok := err.(*exec.ExitError); ok { 18 | return exitError.Sys().(syscall.WaitStatus).ExitStatus() == 0 19 | } 20 | } 21 | } else if runtime.GOOS == "darwin" { 22 | script := `set T to button returned of ` + 23 | `(display dialog "%s" with title "%s" buttons {"No", "Yes"} default button "Yes")` 24 | out, err := exec.Command("osascript", "-e", fmt.Sprintf(script, text, title)).Output() 25 | if err != nil { 26 | if exitError, ok := err.(*exec.ExitError); ok { 27 | return exitError.Sys().(syscall.WaitStatus).ExitStatus() == 0 28 | } 29 | } 30 | return strings.TrimSpace(string(out)) == "Yes" 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /messagebox_windows.go: -------------------------------------------------------------------------------- 1 | //+build windows 2 | 3 | package lorca 4 | 5 | import ( 6 | "syscall" 7 | "unsafe" 8 | ) 9 | 10 | func messageBox(title, text string) bool { 11 | user32 := syscall.NewLazyDLL("user32.dll") 12 | messageBoxW := user32.NewProc("MessageBoxW") 13 | mbYesNo := 0x00000004 14 | mbIconQuestion := 0x00000020 15 | idYes := 6 16 | ret, _, _ := messageBoxW.Call(0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))), 17 | uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), uintptr(uint(mbYesNo|mbIconQuestion))) 18 | return int(ret) == idYes 19 | } 20 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "reflect" 10 | ) 11 | 12 | // UI interface allows talking to the HTML5 UI from Go. 13 | type UI interface { 14 | Load(url string) error 15 | Bounds() (Bounds, error) 16 | SetBounds(Bounds) error 17 | Bind(name string, f interface{}) error 18 | Eval(js string) Value 19 | Done() <-chan struct{} 20 | Close() error 21 | } 22 | 23 | type ui struct { 24 | chrome *chrome 25 | done chan struct{} 26 | tmpDir string 27 | } 28 | 29 | var defaultChromeArgs = []string{ 30 | "--disable-background-networking", 31 | "--disable-background-timer-throttling", 32 | "--disable-backgrounding-occluded-windows", 33 | "--disable-breakpad", 34 | "--disable-client-side-phishing-detection", 35 | "--disable-default-apps", 36 | "--disable-dev-shm-usage", 37 | "--disable-infobars", 38 | "--disable-extensions", 39 | "--disable-features=site-per-process", 40 | "--disable-hang-monitor", 41 | "--disable-ipc-flooding-protection", 42 | "--disable-popup-blocking", 43 | "--disable-prompt-on-repost", 44 | "--disable-renderer-backgrounding", 45 | "--disable-sync", 46 | "--disable-translate", 47 | "--disable-windows10-custom-titlebar", 48 | "--metrics-recording-only", 49 | "--no-first-run", 50 | "--no-default-browser-check", 51 | "--safebrowsing-disable-auto-update", 52 | "--enable-automation", 53 | "--password-store=basic", 54 | "--use-mock-keychain", 55 | "--remote-allow-origins=*", 56 | } 57 | 58 | // New returns a new HTML5 UI for the given URL, user profile directory, window 59 | // size and other options passed to the browser engine. If URL is an empty 60 | // string - a blank page is displayed. If user profile directory is an empty 61 | // string - a temporary directory is created and it will be removed on 62 | // ui.Close(). You might want to use "--headless" custom CLI argument to test 63 | // your UI code. 64 | func New(url, dir string, width, height int, customArgs ...string) (UI, error) { 65 | if url == "" { 66 | url = "data:text/html," 67 | } 68 | tmpDir := "" 69 | if dir == "" { 70 | name, err := ioutil.TempDir("", "lorca") 71 | if err != nil { 72 | return nil, err 73 | } 74 | dir, tmpDir = name, name 75 | } 76 | args := append(defaultChromeArgs, fmt.Sprintf("--app=%s", url)) 77 | args = append(args, fmt.Sprintf("--user-data-dir=%s", dir)) 78 | args = append(args, fmt.Sprintf("--window-size=%d,%d", width, height)) 79 | args = append(args, customArgs...) 80 | args = append(args, "--remote-debugging-port=0") 81 | 82 | chrome, err := newChromeWithArgs(ChromeExecutable(), args...) 83 | done := make(chan struct{}) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | go func() { 89 | chrome.cmd.Wait() 90 | close(done) 91 | }() 92 | return &ui{chrome: chrome, done: done, tmpDir: tmpDir}, nil 93 | } 94 | 95 | func (u *ui) Done() <-chan struct{} { 96 | return u.done 97 | } 98 | 99 | func (u *ui) Close() error { 100 | // ignore err, as the chrome process might be already dead, when user close the window. 101 | u.chrome.kill() 102 | <-u.done 103 | if u.tmpDir != "" { 104 | if err := os.RemoveAll(u.tmpDir); err != nil { 105 | return err 106 | } 107 | } 108 | return nil 109 | } 110 | 111 | func (u *ui) Load(url string) error { return u.chrome.load(url) } 112 | 113 | func (u *ui) Bind(name string, f interface{}) error { 114 | v := reflect.ValueOf(f) 115 | // f must be a function 116 | if v.Kind() != reflect.Func { 117 | return errors.New("only functions can be bound") 118 | } 119 | // f must return either value and error or just error 120 | if n := v.Type().NumOut(); n > 2 { 121 | return errors.New("function may only return a value or a value+error") 122 | } 123 | 124 | return u.chrome.bind(name, func(raw []json.RawMessage) (interface{}, error) { 125 | if len(raw) != v.Type().NumIn() { 126 | return nil, errors.New("function arguments mismatch") 127 | } 128 | args := []reflect.Value{} 129 | for i := range raw { 130 | arg := reflect.New(v.Type().In(i)) 131 | if err := json.Unmarshal(raw[i], arg.Interface()); err != nil { 132 | return nil, err 133 | } 134 | args = append(args, arg.Elem()) 135 | } 136 | errorType := reflect.TypeOf((*error)(nil)).Elem() 137 | res := v.Call(args) 138 | switch len(res) { 139 | case 0: 140 | // No results from the function, just return nil 141 | return nil, nil 142 | case 1: 143 | // One result may be a value, or an error 144 | if res[0].Type().Implements(errorType) { 145 | if res[0].Interface() != nil { 146 | return nil, res[0].Interface().(error) 147 | } 148 | return nil, nil 149 | } 150 | return res[0].Interface(), nil 151 | case 2: 152 | // Two results: first one is value, second is error 153 | if !res[1].Type().Implements(errorType) { 154 | return nil, errors.New("second return value must be an error") 155 | } 156 | if res[1].Interface() == nil { 157 | return res[0].Interface(), nil 158 | } 159 | return res[0].Interface(), res[1].Interface().(error) 160 | default: 161 | return nil, errors.New("unexpected number of return values") 162 | } 163 | }) 164 | } 165 | 166 | func (u *ui) Eval(js string) Value { 167 | v, err := u.chrome.eval(js) 168 | return value{err: err, raw: v} 169 | } 170 | 171 | func (u *ui) SetBounds(b Bounds) error { 172 | return u.chrome.setBounds(b) 173 | } 174 | 175 | func (u *ui) Bounds() (Bounds, error) { 176 | return u.chrome.bounds() 177 | } 178 | -------------------------------------------------------------------------------- /ui_test.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "strconv" 7 | "testing" 8 | ) 9 | 10 | func TestEval(t *testing.T) { 11 | ui, err := New("", "", 480, 320, "--headless") 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | defer ui.Close() 16 | 17 | if n := ui.Eval(`2+3`).Int(); n != 5 { 18 | t.Fatal(n) 19 | } 20 | 21 | if s := ui.Eval(`"foo" + "bar"`).String(); s != "foobar" { 22 | t.Fatal(s) 23 | } 24 | 25 | if a := ui.Eval(`[1,2,3].map(n => n *2)`).Array(); a[0].Int() != 2 || a[1].Int() != 4 || a[2].Int() != 6 { 26 | t.Fatal(a) 27 | } 28 | 29 | // XXX this probably should be unquoted? 30 | if err := ui.Eval(`throw "fail"`).Err(); err.Error() != `"fail"` { 31 | t.Fatal(err) 32 | } 33 | } 34 | 35 | func TestBind(t *testing.T) { 36 | ui, err := New("", "", 480, 320, "--headless") 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | defer ui.Close() 41 | 42 | if err := ui.Bind("add", func(a, b int) int { return a + b }); err != nil { 43 | t.Fatal(err) 44 | } 45 | if err := ui.Bind("rand", func() int { return rand.Int() }); err != nil { 46 | t.Fatal(err) 47 | } 48 | if err := ui.Bind("strlen", func(s string) int { return len(s) }); err != nil { 49 | t.Fatal(err) 50 | } 51 | if err := ui.Bind("atoi", func(s string) (int, error) { return strconv.Atoi(s) }); err != nil { 52 | t.Fatal(err) 53 | } 54 | if err := ui.Bind("shouldFail", "hello"); err == nil { 55 | t.Fail() 56 | } 57 | 58 | if n := ui.Eval(`add(2,3)`); n.Int() != 5 { 59 | t.Fatal(n) 60 | } 61 | if n := ui.Eval(`add(2,3,4)`); n.Err() == nil { 62 | t.Fatal(n) 63 | } 64 | if n := ui.Eval(`add(2)`); n.Err() == nil { 65 | t.Fatal(n) 66 | } 67 | if n := ui.Eval(`add("hello", "world")`); n.Err() == nil { 68 | t.Fatal(n) 69 | } 70 | if n := ui.Eval(`rand()`); n.Err() != nil { 71 | t.Fatal(n) 72 | } 73 | if n := ui.Eval(`rand(100)`); n.Err() == nil { 74 | t.Fatal(n) 75 | } 76 | if n := ui.Eval(`strlen('foo')`); n.Int() != 3 { 77 | t.Fatal(n) 78 | } 79 | if n := ui.Eval(`strlen(123)`); n.Err() == nil { 80 | t.Fatal(n) 81 | } 82 | if n := ui.Eval(`atoi('123')`); n.Int() != 123 { 83 | t.Fatal(n) 84 | } 85 | if n := ui.Eval(`atoi('hello')`); n.Err() == nil { 86 | t.Fatal(n) 87 | } 88 | } 89 | 90 | func TestFunctionReturnTypes(t *testing.T) { 91 | ui, err := New("", "", 480, 320, "--headless") 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | defer ui.Close() 96 | 97 | if err := ui.Bind("noResults", func() { return }); err != nil { 98 | t.Fatal(err) 99 | } 100 | if err := ui.Bind("oneNonNilResult", func() interface{} { return 1 }); err != nil { 101 | t.Fatal(err) 102 | } 103 | if err := ui.Bind("oneNilResult", func() interface{} { return nil }); err != nil { 104 | t.Fatal(err) 105 | } 106 | if err := ui.Bind("oneNonNilErrorResult", func() error { return errors.New("error") }); err != nil { 107 | t.Fatal(err) 108 | } 109 | if err := ui.Bind("oneNilErrorResult", func() error { return nil }); err != nil { 110 | t.Fatal(err) 111 | } 112 | if err := ui.Bind("twoResultsNonNilError", func() (interface{}, error) { return nil, errors.New("error") }); err != nil { 113 | t.Fatal(err) 114 | } 115 | if err := ui.Bind("twoResultsNilError", func() (interface{}, error) { return 1, nil }); err != nil { 116 | t.Fatal(err) 117 | } 118 | if err := ui.Bind("twoResultsBothNonNil", func() (interface{}, error) { return 1, errors.New("error") }); err != nil { 119 | t.Fatal(err) 120 | } 121 | if err := ui.Bind("twoResultsBothNil", func() (interface{}, error) { return nil, nil }); err != nil { 122 | t.Fatal(err) 123 | } 124 | 125 | if v := ui.Eval(`noResults()`); v.Err() != nil { 126 | t.Fatal(v) 127 | } 128 | if v := ui.Eval(`oneNonNilResult()`); v.Int() != 1 { 129 | t.Fatal(v) 130 | } 131 | if v := ui.Eval(`oneNilResult()`); v.Err() != nil { 132 | t.Fatal(v) 133 | } 134 | if v := ui.Eval(`oneNonNilErrorResult()`); v.Err() == nil { 135 | t.Fatal(v) 136 | } 137 | if v := ui.Eval(`oneNilErrorResult()`); v.Err() != nil { 138 | t.Fatal(v) 139 | } 140 | if v := ui.Eval(`twoResultsNonNilError()`); v.Err() == nil { 141 | t.Fatal(v) 142 | } 143 | if v := ui.Eval(`twoResultsNilError()`); v.Err() != nil || v.Int() != 1 { 144 | t.Fatal(v) 145 | } 146 | if v := ui.Eval(`twoResultsBothNonNil()`); v.Err() == nil { 147 | t.Fatal(v) 148 | } 149 | if v := ui.Eval(`twoResultsBothNil()`); v.Err() != nil { 150 | t.Fatal(v) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /value.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import "encoding/json" 4 | 5 | // Value is a generic type of a JSON value (primitive, object, array) and 6 | // optionally an error value. 7 | type Value interface { 8 | Err() error 9 | To(interface{}) error 10 | Float() float32 11 | Int() int 12 | String() string 13 | Bool() bool 14 | Object() map[string]Value 15 | Array() []Value 16 | Bytes() []byte 17 | } 18 | 19 | type value struct { 20 | err error 21 | raw json.RawMessage 22 | } 23 | 24 | func (v value) Err() error { return v.err } 25 | func (v value) Bytes() []byte { return v.raw } 26 | func (v value) To(x interface{}) error { return json.Unmarshal(v.raw, x) } 27 | func (v value) Float() (f float32) { v.To(&f); return f } 28 | func (v value) Int() (i int) { v.To(&i); return i } 29 | func (v value) String() (s string) { v.To(&s); return s } 30 | func (v value) Bool() (b bool) { v.To(&b); return b } 31 | func (v value) Array() (values []Value) { 32 | array := []json.RawMessage{} 33 | v.To(&array) 34 | for _, el := range array { 35 | values = append(values, value{raw: el}) 36 | } 37 | return values 38 | } 39 | func (v value) Object() (object map[string]Value) { 40 | object = map[string]Value{} 41 | kv := map[string]json.RawMessage{} 42 | v.To(&kv) 43 | for k, v := range kv { 44 | object[k] = value{raw: v} 45 | } 46 | return object 47 | } 48 | -------------------------------------------------------------------------------- /value_test.go: -------------------------------------------------------------------------------- 1 | package lorca 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "testing" 8 | ) 9 | 10 | var errTest = errors.New("fail") 11 | 12 | func TestValueError(t *testing.T) { 13 | v := value{err: errTest} 14 | if v.Err() != errTest { 15 | t.Fail() 16 | } 17 | 18 | v = value{raw: json.RawMessage(`"hello"`)} 19 | if v.Err() != nil { 20 | t.Fail() 21 | } 22 | } 23 | 24 | func TestValuePrimitive(t *testing.T) { 25 | v := value{raw: json.RawMessage(`42`)} 26 | if v.Int() != 42 { 27 | t.Fail() 28 | } 29 | v = value{raw: json.RawMessage(`"hello"`)} 30 | if v.Int() != 0 || v.String() != "hello" { 31 | t.Fail() 32 | } 33 | v = value{err: errTest} 34 | if v.Int() != 0 || v.String() != "" { 35 | t.Fail() 36 | } 37 | } 38 | 39 | func TestValueComplex(t *testing.T) { 40 | v := value{raw: json.RawMessage(`["foo", 42.3, {"x": 5}]`)} 41 | if len(v.Array()) != 3 { 42 | t.Fail() 43 | } 44 | if v.Array()[0].String() != "foo" { 45 | t.Fail() 46 | } 47 | if v.Array()[1].Float() != 42.3 { 48 | t.Fail() 49 | } 50 | if v.Array()[2].Object()["x"].Int() != 5 { 51 | t.Fail() 52 | } 53 | } 54 | 55 | func TestRawValue(t *testing.T) { 56 | var v Value 57 | 58 | v = value{raw: json.RawMessage(nil)} 59 | if v.Bytes() != nil { 60 | t.Fail() 61 | } 62 | 63 | v = value{raw: json.RawMessage(`"hello"`)} 64 | if !bytes.Equal(v.Bytes(), []byte(`"hello"`)) { 65 | t.Fail() 66 | } 67 | } 68 | --------------------------------------------------------------------------------