├── .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 | [](https://github.com/zserge/lorca)
4 | [](https://godoc.org/github.com/zserge/lorca)
5 | [](https://goreportcard.com/report/github.com/zserge/lorca)
6 |
7 |
8 |
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 |