diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10f8899..4f84666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,24 +2,30 @@ name: CI Pipeline on: [push, pull_request] jobs: - build: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Install Chrome - run: | - sudo apt-get update - sudo apt-get install -yqq google-chrome-stable - - name: Install Go - uses: actions/setup-go@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 with: - go-version: '1.16' + go-version: stable + cache: true + cache-dependency-path: go.sum + + - name: Install Chrome + uses: browser-actions/setup-chrome@v1 + id: setup-chrome + + - name: Install Firefox + uses: browser-actions/setup-firefox@v1 + id: setup-firefox + + - name: Vet + run: go vet ./... + - name: Run tests - run: go test -v -race ./... - - name: Build examples env: - CGO_ENABLED: 0 - run: | - go build -o example-hello ./examples/hello - go build -o example-stopwatch ./examples/stopwatch - go build -o example-counter ./examples/counter + LORCACHROME: ${{ steps.setup-chrome.outputs.chrome-path }} + LORCAFIREFOX: ${{ steps.setup-firefox.outputs.firefox-path }} + run: go test -v -race -count=1 ./... diff --git a/.gitignore b/.gitignore index 2ceb2e7..8a4da27 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ example/Example.app !/.idea/inspectionProfiles/Project_Default.xml !/.idea/dictionaries/*.xml !/.idea/go.xml +/docs \ No newline at end of file diff --git a/LICENSE b/LICENSE index 79a518d..22cd25e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Serge Zaitsev +Copyright (c) 2024 David Arthur Cole Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f7cc83b..36adff4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # Lorca -[](https://github.com/zserge/lorca) -[](https://godoc.org/github.com/zserge/lorca) -[](https://goreportcard.com/report/github.com/zserge/lorca) +[](https://github.com/DavidArthurCole/lorca) +[](https://godoc.org/github.com/DavidArthurCole/lorca) +[](https://goreportcard.com/report/github.com/DavidArthurCole/lorca)
+
A very small library to build modern HTML5 desktop apps in Go. It uses Chrome
diff --git a/browser.go b/browser.go
new file mode 100644
index 0000000..28ce910
--- /dev/null
+++ b/browser.go
@@ -0,0 +1,24 @@
+package lorca
+
+import "encoding/json"
+
+// browserImpl is the internal interface each browser backend implements.
+// ui holds a browserImpl and delegates all browser interactions through it.
+type browserImpl interface {
+ eval(expr string) (json.RawMessage, error)
+ load(url string) error
+ bounds() (Bounds, error)
+ setBounds(Bounds) error
+ injectScript(js string) error // registers script for all future docs + runs on current page
+ injectBinding(name string) error // like injectScript but avoids cross-realm calls for Firefox
+ setBlockBackNavigation(enable bool)
+ setAppUserModelID(id string)
+ kill()
+ done() <-chan struct{}
+}
+
+// Compile-time interface checks.
+var (
+ _ browserImpl = (*chrome)(nil)
+ _ browserImpl = (*firefox)(nil)
+)
diff --git a/chrome.go b/chrome.go
index b17b803..9329fba 100644
--- a/chrome.go
+++ b/chrome.go
@@ -1,22 +1,23 @@
package lorca
import (
- "bufio"
"encoding/json"
"errors"
"fmt"
"io"
- "io/ioutil"
"log"
+ "net"
+ "net/http"
"os/exec"
- "regexp"
+ "strings"
"sync"
"sync/atomic"
+ "time"
"golang.org/x/net/websocket"
)
-type h = map[string]interface{}
+type h = map[string]any
// Result is a struct for the resulting value of the JS expression or an error.
type result struct {
@@ -24,7 +25,7 @@ type result struct {
Err error
}
-type bindingFunc func(args []json.RawMessage) (interface{}, error)
+type bindingFunc func(args []json.RawMessage) (any, error)
// Msg is a struct for incoming messages (results and async events)
type msg struct {
@@ -37,42 +38,85 @@ type msg struct {
type chrome struct {
sync.Mutex
- cmd *exec.Cmd
- ws *websocket.Conn
- id int32
- target string
- session string
- window int
- pending map[int]chan result
- bindings map[string]bindingFunc
+ wsMu sync.Mutex // serializes websocket writes
+ cmd *exec.Cmd
+ ws *websocket.Conn
+ id int32
+ target string
+ session string
+ window int
+ pending map[int]chan result
+ debugPort int
+ doneC chan struct{}
+ appURL string // URL set by load(); used to redirect back-navigation
+ blockBackNav bool // when true, navigations away from appURL are redirected back
+}
+
+type browserVersion struct {
+ Browser string `json:"Browser"`
+ ProtocolVersion string `json:"Protocol-Version"`
+ UserAgent string `json:"User-Agent"`
+ V8Version string `json:"V8-Version"`
+ WebkitVersion string `json:"Webkit-Version"`
+ WebSocketDebuggerUrl string `json:"webSocketDebuggerUrl"`
+}
+
+func getFreePort() (int, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return 0, err
+ }
+ defer ln.Close()
+ return ln.Addr().(*net.TCPAddr).Port, nil
}
func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, error) {
// The first two IDs are used internally during the initialization
c := &chrome{
- id: 2,
- pending: map[int]chan result{},
- bindings: map[string]bindingFunc{},
+ id: 2,
+ pending: map[int]chan result{},
}
- // Start chrome process
- c.cmd = exec.Command(chromeBinary, args...)
- pipe, err := c.cmd.StderrPipe()
+ debugPort, err := getFreePort()
if err != nil {
return nil, err
}
+
+ // Start chrome process
+ args = append(args, fmt.Sprintf("--remote-debugging-port=%d", debugPort))
+ c.cmd = exec.Command(chromeBinary, args...)
if err := c.cmd.Start(); err != nil {
return nil, err
}
- // Wait for websocket address to be printed to stderr
- re := regexp.MustCompile(`^DevTools listening on (ws://.*?)\r?\n$`)
- m, err := readUntilMatch(pipe, re)
+ // Retry mechanism
+ startTime := time.Now()
+ var res *http.Response
+ for {
+ res, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort))
+ if err == nil {
+ break
+ }
+ if time.Since(startTime) > 5*time.Second {
+ return nil, fmt.Errorf("failed to reach /json/version within 5 seconds: %w", err)
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ c.debugPort = debugPort
+
+ body, err := io.ReadAll(res.Body)
if err != nil {
- c.kill()
return nil, err
}
- wsURL := m[1]
+
+ browserVer := &browserVersion{}
+
+ err = json.Unmarshal(body, &browserVer)
+ if err != nil {
+ return nil, err
+ }
+
+ wsURL := browserVer.WebSocketDebuggerUrl
// Open a websocket
c.ws, err = websocket.Dial(wsURL, "", "http://127.0.0.1")
@@ -93,7 +137,10 @@ func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, error) {
c.kill()
return nil, err
}
+ c.doneC = make(chan struct{})
+ go func() { c.cmd.Wait(); close(c.doneC) }()
go c.readLoop()
+
for method, args := range map[string]h{
"Page.enable": nil,
"Target.setAutoAttach": {"autoAttach": true, "waitForDebuggerOnStart": false},
@@ -105,7 +152,6 @@ func newChromeWithArgs(chromeBinary string, args ...string) (*chrome, error) {
} {
if _, err := c.send(method, args); err != nil {
c.kill()
- c.cmd.Wait()
return nil, err
}
}
@@ -244,7 +290,10 @@ type targetMessage struct {
} `json:"result"`
Exception struct {
Exception struct {
- Value json.RawMessage `json:"value"`
+ Type string `json:"type"`
+ Subtype string `json:"subtype"`
+ Description string `json:"description"`
+ Value json.RawMessage `json:"value"`
} `json:"exception"`
} `json:"exceptionDetails"`
} `json:"result"`
@@ -271,41 +320,32 @@ func (c *chrome) readLoop() {
if res.ID == 0 && res.Method == "Runtime.consoleAPICalled" || res.Method == "Runtime.exceptionThrown" {
log.Println(params.Message)
- } else if res.ID == 0 && res.Method == "Runtime.bindingCalled" {
- payload := struct {
- Name string `json:"name"`
- Seq int `json:"seq"`
- Args []json.RawMessage `json:"args"`
- }{}
- json.Unmarshal([]byte(res.Params.Payload), &payload)
+ }
+ if res.Method == "Page.frameNavigated" {
c.Lock()
- binding, ok := c.bindings[res.Params.Name]
+ blockBackNav := c.blockBackNav
+ appURL := c.appURL
c.Unlock()
- if ok {
- jsString := func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }
- go func() {
- result, error := "", `""`
- if r, err := binding(payload.Args); err != nil {
- error = jsString(err.Error())
- } else if b, err := json.Marshal(r); err != nil {
- error = jsString(err.Error())
- } else {
- result = string(b)
+ if blockBackNav && appURL != "" {
+ var navEvent struct {
+ Params struct {
+ Frame struct {
+ URL string `json:"url"`
+ } `json:"frame"`
+ } `json:"params"`
+ }
+ if json.Unmarshal([]byte(params.Message), &navEvent) == nil {
+ navURL := navEvent.Params.Frame.URL
+ if !strings.HasPrefix(navURL, appURL) {
+ go func(redirectURL string) {
+ if _, err := c.send("Page.navigate", h{"url": redirectURL}); err != nil {
+ log.Printf("lorca/chrome: redirect to appURL failed: %v", err)
+ }
+ }(appURL)
}
- expr := fmt.Sprintf(`
- if (%[4]s) {
- window['%[1]s']['errors'].get(%[2]d)(%[4]s);
- } else {
- window['%[1]s']['callbacks'].get(%[2]d)(%[3]s);
- }
- window['%[1]s']['callbacks'].delete(%[2]d);
- window['%[1]s']['errors'].delete(%[2]d);
- `, payload.Name, payload.Seq, result, error)
- c.send("Runtime.evaluate", h{"expression": expr, "contextId": res.Params.ID})
- }()
+ }
}
- continue
}
c.Lock()
@@ -321,6 +361,8 @@ func (c *chrome) readLoop() {
resc <- result{Err: errors.New(res.Error.Message)}
} else if res.Result.Exception.Exception.Value != nil {
resc <- result{Err: errors.New(string(res.Result.Exception.Exception.Value))}
+ } else if res.Result.Exception.Exception.Subtype == "error" {
+ resc <- result{Err: errors.New(res.Result.Exception.Exception.Description)}
} else if res.Result.Result.Type == "object" && res.Result.Result.Subtype == "error" {
resc <- result{Err: errors.New(res.Result.Result.Description)}
} else if res.Result.Result.Type != "" {
@@ -354,11 +396,14 @@ func (c *chrome) send(method string, params h) (json.RawMessage, error) {
c.pending[int(id)] = resc
c.Unlock()
- if err := websocket.JSON.Send(c.ws, h{
+ c.wsMu.Lock()
+ err = websocket.JSON.Send(c.ws, h{
"id": int(id),
"method": "Target.sendMessageToTarget",
"params": h{"message": string(b), "sessionId": c.session},
- }); err != nil {
+ })
+ c.wsMu.Unlock()
+ if err != nil {
return nil, err
}
res := <-resc
@@ -367,61 +412,24 @@ func (c *chrome) send(method string, params h) (json.RawMessage, error) {
func (c *chrome) load(url string) error {
_, err := c.send("Page.navigate", h{"url": url})
+ if err == nil {
+ c.Lock()
+ c.appURL = url
+ c.Unlock()
+ }
return err
}
-func (c *chrome) eval(expr string) (json.RawMessage, error) {
- return c.send("Runtime.evaluate", h{"expression": expr, "awaitPromise": true, "returnByValue": true})
-}
-
-func (c *chrome) bind(name string, f bindingFunc) error {
+func (c *chrome) setBlockBackNavigation(enable bool) {
c.Lock()
- // check if binding already exists
- _, exists := c.bindings[name]
-
- c.bindings[name] = f
+ c.blockBackNav = enable
c.Unlock()
+}
- if exists {
- // Just replace callback and return, as the binding was already added to js
- // and adding it again would break it.
- return nil
- }
+func (c *chrome) setAppUserModelID(_ string) {}
- if _, err := c.send("Runtime.addBinding", h{"name": name}); err != nil {
- return err
- }
- script := fmt.Sprintf(`(() => {
- const bindingName = '%s';
- const binding = window[bindingName];
- window[bindingName] = async (...args) => {
- const me = window[bindingName];
- let errors = me['errors'];
- let callbacks = me['callbacks'];
- if (!callbacks) {
- callbacks = new Map();
- me['callbacks'] = callbacks;
- }
- if (!errors) {
- errors = new Map();
- me['errors'] = errors;
- }
- const seq = (me['lastSeq'] || 0) + 1;
- me['lastSeq'] = seq;
- const promise = new Promise((resolve, reject) => {
- callbacks.set(seq, resolve);
- errors.set(seq, reject);
- });
- binding(JSON.stringify({name: bindingName, seq, args}));
- return promise;
- }})();
- `, name)
- _, err := c.send("Page.addScriptToEvaluateOnNewDocument", h{"source": script})
- if err != nil {
- return err
- }
- _, err = c.eval(script)
- return err
+func (c *chrome) eval(expr string) (json.RawMessage, error) {
+ return c.send("Runtime.evaluate", h{"expression": expr, "awaitPromise": true, "returnByValue": true})
}
func (c *chrome) setBounds(b Bounds) error {
@@ -503,30 +511,60 @@ func (c *chrome) png(x, y, width, height int, bg uint32, scale float32) ([]byte,
return pdf.Data, err
}
-func (c *chrome) kill() error {
+func (c *chrome) kill() {
if c.ws != nil {
- if err := c.ws.Close(); err != nil {
- return err
- }
+ c.ws.Close()
}
- // TODO: cancel all pending requests
+ c.Lock()
+ for _, ch := range c.pending {
+ ch <- result{Err: errors.New("chrome closed")}
+ }
+ c.pending = map[int]chan result{}
+ c.Unlock()
+
if state := c.cmd.ProcessState; state == nil || !state.Exited() {
- return c.cmd.Process.Kill()
+ killProcessTree(c.cmd.Process.Pid)
}
- return nil
}
-func readUntilMatch(r io.ReadCloser, re *regexp.Regexp) ([]string, error) {
- br := bufio.NewReader(r)
- for {
- if line, err := br.ReadString('\n'); err != nil {
- r.Close()
- return nil, err
- } else if m := re.FindStringSubmatch(line); m != nil {
- go io.Copy(ioutil.Discard, br)
- return m, nil
- }
+func (c *chrome) done() <-chan struct{} { return c.doneC }
+
+func (c *chrome) injectScript(js string) error {
+ if _, err := c.send("Page.addScriptToEvaluateOnNewDocument", h{"source": js}); err != nil {
+ return err
}
+ _, err := c.eval(js)
+ return err
+}
+
+// evalNoWait sends a Runtime.evaluate command without waiting for the
+// response. The response is received by readLoop and silently dropped
+// (no entry in c.pending). Used for fire-and-forget evals where
+// correctness is ensured by another path (e.g. addScriptToEvaluateOnNewDocument).
+func (c *chrome) evalNoWait(expr string) {
+ id := int(atomic.AddInt32(&c.id, 1))
+ b, _ := json.Marshal(h{"id": id, "method": "Runtime.evaluate", "params": h{"expression": expr, "awaitPromise": false}})
+ c.wsMu.Lock()
+ websocket.JSON.Send(c.ws, h{ //nolint:errcheck
+ "id": id,
+ "method": "Target.sendMessageToTarget",
+ "params": h{"message": string(b), "sessionId": c.session},
+ })
+ c.wsMu.Unlock()
+}
+
+func (c *chrome) injectBinding(name string) error {
+ script := bindingScript(name)
+ // Register the binding script for all future page loads (authoritative path).
+ if _, err := c.send("Page.addScriptToEvaluateOnNewDocument", h{"source": script}); err != nil {
+ return err
+ }
+ // Fire-and-forget eval on the current page. With 50+ bindings registered
+ // at startup, blocking on each eval adds up. The addScriptToEvaluateOnNewDocument
+ // above ensures the binding is installed on the next navigation; this eval
+ // is only a convenience for the currently-loaded page.
+ c.evalNoWait(script)
+ return nil
}
func contains(arr []string, x string) bool {
diff --git a/chrome_nonwindows.go b/chrome_nonwindows.go
new file mode 100644
index 0000000..cb1b47d
--- /dev/null
+++ b/chrome_nonwindows.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package lorca
+
+import "os"
+
+func killProcessTree(pid int) error {
+ p, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return p.Kill()
+}
diff --git a/chrome_test.go b/chrome_test.go
index 8a4eecf..51616e7 100644
--- a/chrome_test.go
+++ b/chrome_test.go
@@ -1,16 +1,45 @@
package lorca
import (
- "encoding/json"
- "errors"
"strings"
- "sync"
- "sync/atomic"
"testing"
+ "time"
)
+func TestChromeInjectScript(t *testing.T) {
+ c, err := newChromeWithArgs(ChromeExecutable(""), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0", "--remote-allow-origins=*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.kill()
+ if err := c.injectScript(`window.__injected = 42`); err != nil {
+ t.Fatal(err)
+ }
+ result, err := c.eval(`window.__injected`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(result) != `42` {
+ t.Fatalf("expected 42, got %s", result)
+ }
+}
+
+func TestChromeDone(t *testing.T) {
+ c, err := newChromeWithArgs(ChromeExecutable(""), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0", "--remote-allow-origins=*")
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.kill()
+ select {
+ case <-c.done():
+ // ok - channel closed after kill
+ case <-time.After(5 * time.Second):
+ t.Fatal("done() channel did not close within 5s after kill")
+ }
+}
+
func TestChromeEval(t *testing.T) {
- c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0")
+ c, err := newChromeWithArgs(ChromeExecutable(""), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0", "--remote-allow-origins=*")
if err != nil {
t.Fatal(err)
}
@@ -44,7 +73,7 @@ func TestChromeEval(t *testing.T) {
}
func TestChromeLoad(t *testing.T) {
- c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0")
+ c, err := newChromeWithArgs(ChromeExecutable(""), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0", "--remote-allow-origins=*")
if err != nil {
t.Fatal(err)
}
@@ -69,74 +98,3 @@ func TestChromeLoad(t *testing.T) {
}
}
-func TestChromeBind(t *testing.T) {
- c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0")
- if err != nil {
- t.Fatal(err)
- }
- defer c.kill()
-
- if err := c.bind("add", func(args []json.RawMessage) (interface{}, error) {
- a, b := 0, 0
- if len(args) != 2 {
- return nil, errors.New("2 arguments expected")
- }
- if err := json.Unmarshal(args[0], &a); err != nil {
- return nil, err
- }
- if err := json.Unmarshal(args[1], &b); err != nil {
- return nil, err
- }
- return a + b, nil
- }); err != nil {
- t.Fatal(err)
- }
-
- if res, err := c.eval(`window.add(2, 3)`); err != nil {
- t.Fatal(err)
- } else if string(res) != `5` {
- t.Fatal(string(res))
- }
-
- if res, err := c.eval(`window.add("foo", "bar")`); err == nil {
- t.Fatal(string(res), err)
- }
- if res, err := c.eval(`window.add(1, 2, 3)`); err == nil {
- t.Fatal(res, err)
- }
-}
-
-func TestChromeAsync(t *testing.T) {
- c, err := newChromeWithArgs(ChromeExecutable(), "--user-data-dir=/tmp", "--headless", "--remote-debugging-port=0")
- if err != nil {
- t.Fatal(err)
- }
- defer c.kill()
-
- if err := c.bind("len", func(args []json.RawMessage) (interface{}, error) {
- return len(args[0]), nil
- }); err != nil {
- t.Fatal(err)
- }
-
- wg := &sync.WaitGroup{}
- n := 10
- failed := int32(0)
- wg.Add(n)
- for i := 0; i < n; i++ {
- go func(i int) {
- defer wg.Done()
- v, err := c.eval("len('hello')")
- if string(v) != `7` {
- atomic.StoreInt32(&failed, 1)
- } else if err != nil {
- atomic.StoreInt32(&failed, 2)
- }
- }(i)
- }
- wg.Wait()
-
- if status := atomic.LoadInt32(&failed); status != 0 {
- t.Fatal()
- }
-}
diff --git a/chrome_windows.go b/chrome_windows.go
new file mode 100644
index 0000000..70d996a
--- /dev/null
+++ b/chrome_windows.go
@@ -0,0 +1,12 @@
+//go:build windows
+
+package lorca
+
+import (
+ "os/exec"
+ "strconv"
+)
+
+func killProcessTree(pid int) error {
+ return exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid)).Run()
+}
diff --git a/examples/counter/build-linux.sh b/examples/counter/build-linux.sh
deleted file mode 100755
index 6d6a619..0000000
--- a/examples/counter/build-linux.sh
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/bin/sh
-
-APP=lorca-example
-APPDIR=${APP}_1.0.0
-
-mkdir -p $APPDIR/usr/bin
-mkdir -p $APPDIR/usr/share/applications
-mkdir -p $APPDIR/usr/share/icons/hicolor/1024x1024/apps
-mkdir -p $APPDIR/usr/share/icons/hicolor/256x256/apps
-mkdir -p $APPDIR/DEBIAN
-
-go build -o $APPDIR/usr/bin/$APP
-
-cp icons/icon.png $APPDIR/usr/share/icons/hicolor/1024x1024/apps/${APP}.png
-cp icons/icon.png $APPDIR/usr/share/icons/hicolor/256x256/apps/${APP}.png
-
-cat > $APPDIR/usr/share/applications/${APP}.desktop << EOF
-[Desktop Entry]
-Version=1.0
-Type=Application
-Name=$APP
-Exec=$APP
-Icon=$APP
-Terminal=false
-StartupWMClass=Lorca
-EOF
-
-cat > $APPDIR/DEBIAN/control << EOF
-Package: ${APP}
-Version: 1.0-0
-Section: base
-Priority: optional
-Architecture: amd64
-Maintainer: Serge Zaitsev Hello, world!
-
- `), "", 480, 320)
- if err != nil {
- log.Fatal(err)
- }
- defer ui.Close()
- // Wait until UI window is closed
- <-ui.Done()
-}
diff --git a/examples/stopwatch/main.go b/examples/stopwatch/main.go
deleted file mode 100644
index a87b823..0000000
--- a/examples/stopwatch/main.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package main
-
-import (
- "fmt"
- "log"
- "net/url"
- "sync/atomic"
- "time"
-
- "github.com/zserge/lorca"
-)
-
-func main() {
- ui, err := lorca.New("", "", 480, 320)
- if err != nil {
- log.Fatal(err)
- }
- defer ui.Close()
-
- // Data model: number of ticks
- ticks := uint32(0)
- // Channel to connect UI events with the background ticking goroutine
- togglec := make(chan bool)
- // Bind Go functions to JS
- ui.Bind("toggle", func() { togglec <- true })
- ui.Bind("reset", func() {
- atomic.StoreUint32(&ticks, 0)
- ui.Eval(`document.querySelector('.timer').innerText = '0'`)
- })
-
- // Load HTML after Go functions are bound to JS
- ui.Load("data:text/html," + url.PathEscape(`
-
-
-
-
-
-
-
- `))
-
- // Start ticker goroutine
- go func() {
- t := time.NewTicker(100 * time.Millisecond)
- for {
- select {
- case <-t.C: // Every 100ms increate number of ticks and update UI
- ui.Eval(fmt.Sprintf(`document.querySelector('.timer').innerText = 0.1*%d`,
- atomic.AddUint32(&ticks, 1)))
- case <-togglec: // If paused - wait for another toggle event to unpause
- <-togglec
- }
- }
- }()
- <-ui.Done()
-}
diff --git a/export.go b/export.go
index 7e35100..4bced0d 100644
--- a/export.go
+++ b/export.go
@@ -2,7 +2,6 @@ package lorca
import (
"fmt"
- "io/ioutil"
"os"
)
@@ -45,13 +44,13 @@ func PNG(url, script string, x, y, width, height int, bg uint32, scale float32)
}
func doHeadless(url string, f func(c *chrome) ([]byte, error)) ([]byte, error) {
- dir, err := ioutil.TempDir("", "lorca")
+ dir, err := os.MkdirTemp("", "lorca")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
args := append(defaultChromeArgs, fmt.Sprintf("--user-data-dir=%s", dir), "--remote-debugging-port=0", "--headless", url)
- chrome, err := newChromeWithArgs(ChromeExecutable(), args...)
+ chrome, err := newChromeWithArgs(ChromeExecutable(""), args...)
if err != nil {
return nil, err
}
diff --git a/firefox.go b/firefox.go
new file mode 100644
index 0000000..98dc569
--- /dev/null
+++ b/firefox.go
@@ -0,0 +1,850 @@
+package lorca
+
+import (
+ "bufio"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "os/exec"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+var defaultFirefoxArgs = []string{
+ "--no-remote",
+ "--new-instance",
+}
+
+// bidiValueToJSON converts a WebDriver BiDi serialized value to plain JSON.
+// BiDi wraps every value in a {"type":"...", "value":...} envelope; this
+// function unwraps it recursively so callers get standard json.RawMessage.
+func bidiValueToJSON(v json.RawMessage) (json.RawMessage, error) {
+ var wrapper struct {
+ Type string `json:"type"`
+ Value json.RawMessage `json:"value"`
+ }
+ if err := json.Unmarshal(v, &wrapper); err != nil {
+ return nil, err
+ }
+ switch wrapper.Type {
+ case "string", "number", "boolean":
+ return wrapper.Value, nil
+ case "null", "undefined":
+ return json.RawMessage("null"), nil
+ case "array":
+ var items []json.RawMessage
+ if err := json.Unmarshal(wrapper.Value, &items); err != nil {
+ return nil, err
+ }
+ converted := make([]json.RawMessage, len(items))
+ for i, item := range items {
+ c, err := bidiValueToJSON(item)
+ if err != nil {
+ return nil, err
+ }
+ converted[i] = c
+ }
+ b, err := json.Marshal(converted)
+ return json.RawMessage(b), err
+ case "object":
+ // BiDi object value is [[keyString, bidiValue], ...]
+ var pairs [][2]json.RawMessage
+ if err := json.Unmarshal(wrapper.Value, &pairs); err != nil {
+ return nil, err
+ }
+ obj := make(map[string]json.RawMessage, len(pairs))
+ for _, pair := range pairs {
+ var key string
+ if err := json.Unmarshal(pair[0], &key); err != nil {
+ return nil, err
+ }
+ val, err := bidiValueToJSON(pair[1])
+ if err != nil {
+ return nil, err
+ }
+ obj[key] = val
+ }
+ b, err := json.Marshal(obj)
+ return json.RawMessage(b), err
+ default:
+ return json.RawMessage("null"), nil
+ }
+}
+
+// bidiConn is a minimal WebSocket client for the Firefox BiDi protocol.
+// We avoid golang.org/x/net/websocket because it always sends an Origin header
+// that Firefox's /session endpoint rejects with 400 Bad Request.
+type bidiConn struct {
+ conn net.Conn
+ br *bufio.Reader
+ writeMu sync.Mutex
+}
+
+func newBidiConn(wsLoc *url.URL) (*bidiConn, error) {
+ addr := wsLoc.Host
+ conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
+ if err != nil {
+ return nil, err
+ }
+ bc := &bidiConn{conn: conn, br: bufio.NewReader(conn)}
+ if err := bc.handshake(wsLoc.Host, wsLoc.Path); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return bc, nil
+}
+
+func (c *bidiConn) handshake(host, path string) error {
+ keyBytes := make([]byte, 16)
+ if _, err := rand.Read(keyBytes); err != nil {
+ return err
+ }
+ key := base64.StdEncoding.EncodeToString(keyBytes)
+
+ req := "GET " + path + " HTTP/1.1\r\n" +
+ "Host: " + host + "\r\n" +
+ "Upgrade: websocket\r\n" +
+ "Connection: Upgrade\r\n" +
+ "Sec-WebSocket-Key: " + key + "\r\n" +
+ "Sec-WebSocket-Version: 13\r\n" +
+ "Sec-WebSocket-Protocol: webdriver-bidi\r\n" +
+ "\r\n"
+
+ c.conn.SetDeadline(time.Now().Add(10 * time.Second))
+ if _, err := c.conn.Write([]byte(req)); err != nil {
+ return err
+ }
+
+ // Read status line.
+ statusLine, err := c.br.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("firefox: BiDi handshake read: %w", err)
+ }
+ if !strings.HasPrefix(statusLine, "HTTP/1.1 101") {
+ return fmt.Errorf("firefox: BiDi handshake: %s", strings.TrimSpace(statusLine))
+ }
+ // Drain the rest of the HTTP headers.
+ for {
+ line, err := c.br.ReadString('\n')
+ if err != nil {
+ return fmt.Errorf("firefox: BiDi handshake drain: %w", err)
+ }
+ if line == "\r\n" {
+ break
+ }
+ }
+ c.conn.SetDeadline(time.Time{})
+ return nil
+}
+
+// writeFrame sends a masked WebSocket frame (RFC 6455 §5.3: all client frames must be masked).
+func (c *bidiConn) writeFrame(opcode byte, payload []byte) error {
+ plen := len(payload)
+ var header []byte
+ header = append(header, 0x80|opcode) // FIN=1, RSV=0
+
+ var maskKey [4]byte
+ rand.Read(maskKey[:])
+
+ switch {
+ case plen <= 125:
+ header = append(header, byte(0x80|plen))
+ case plen <= 65535:
+ header = append(header, 0xFE, byte(plen>>8), byte(plen))
+ default:
+ header = append(header, 0xFF,
+ 0, 0, 0, 0,
+ byte(plen>>24), byte(plen>>16), byte(plen>>8), byte(plen))
+ }
+ header = append(header, maskKey[:]...)
+
+ masked := make([]byte, plen)
+ for i, b := range payload {
+ masked[i] = b ^ maskKey[i%4]
+ }
+
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ _, err := c.conn.Write(append(header, masked...))
+ return err
+}
+
+// Send marshals v to JSON and sends it as a WebSocket text frame.
+func (c *bidiConn) Send(v interface{}) error {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return c.writeFrame(0x1, data)
+}
+
+// Receive reads the next WebSocket text/binary frame and unmarshals its
+// payload into v. Control frames (ping/pong/close) are handled inline.
+func (c *bidiConn) Receive(v interface{}) error {
+ for {
+ hdr := make([]byte, 2)
+ if _, err := io.ReadFull(c.br, hdr); err != nil {
+ return err
+ }
+
+ opcode := hdr[0] & 0x0F
+ isMasked := hdr[1]&0x80 != 0
+ plen := int(hdr[1] & 0x7F)
+
+ switch plen {
+ case 126:
+ ext := make([]byte, 2)
+ if _, err := io.ReadFull(c.br, ext); err != nil {
+ return err
+ }
+ plen = int(ext[0])<<8 | int(ext[1])
+ case 127:
+ ext := make([]byte, 8)
+ if _, err := io.ReadFull(c.br, ext); err != nil {
+ return err
+ }
+ // Lower 32 bits suffice; payloads > 4 GB are not expected.
+ plen = int(ext[4])<<24 | int(ext[5])<<16 | int(ext[6])<<8 | int(ext[7])
+ }
+
+ var maskKey [4]byte
+ if isMasked {
+ if _, err := io.ReadFull(c.br, maskKey[:]); err != nil {
+ return err
+ }
+ }
+
+ payload := make([]byte, plen)
+ if _, err := io.ReadFull(c.br, payload); err != nil {
+ return err
+ }
+ if isMasked {
+ for i := range payload {
+ payload[i] ^= maskKey[i%4]
+ }
+ }
+
+ switch opcode {
+ case 0x8: // close
+ return io.EOF
+ case 0x9: // ping -reply with pong
+ c.writeFrame(0xA, payload)
+ continue
+ case 0xA: // pong -ignore
+ continue
+ case 0x0, 0x1, 0x2: // continuation, text, binary
+ return json.Unmarshal(payload, v)
+ default:
+ continue // unknown opcode; skip
+ }
+ }
+}
+
+// Close closes the underlying TCP connection.
+func (c *bidiConn) Close() error {
+ return c.conn.Close()
+}
+
+type firefox struct {
+ sync.Mutex
+ cmd *exec.Cmd
+ bidi *bidiConn
+ id int32
+ context string // WebDriver BiDi browsing context ID
+ pending map[int]chan result
+ doneC chan struct{}
+ doneOnce sync.Once
+ watchdogDoneC chan struct{}
+ watchdogOnce sync.Once
+ lastBounds Bounds
+ debugPort int
+ loadScripts []string // scripts re-eval'd in page realm on every browsingContext.load
+ appURL string // URL set by load(); used to redirect back-navigation
+ blockBackNav bool // when true, navigations away from appURL are redirected back
+}
+
+// closeDone closes doneC exactly once, signalling that the real Firefox process is gone.
+// Called by readLoop so Done() reflects BiDi connection drop, not the launcher stub exit.
+func (f *firefox) closeDone() {
+ f.doneOnce.Do(func() { close(f.doneC) })
+}
+
+func newFirefoxWithArgs(binary string, iconPath string, args ...string) (*firefox, error) {
+ f := &firefox{
+ id: 1, // 0 used for session.new, 1 for getTree during init; send() increments before use
+ pending: map[int]chan result{},
+ }
+
+ debugPort, err := getFreePort()
+ if err != nil {
+ return nil, err
+ }
+ f.debugPort = debugPort
+
+ args = append(args, fmt.Sprintf("--remote-debugging-port=%d", debugPort))
+ f.cmd = exec.Command(binary, args...)
+ if err := f.cmd.Start(); err != nil {
+ return nil, err
+ }
+
+ // Poll /json/version until Firefox is ready.
+ startTime := time.Now()
+ var res *http.Response
+ for {
+ res, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", debugPort))
+ if err == nil {
+ break
+ }
+ if time.Since(startTime) > 5*time.Second {
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, fmt.Errorf("firefox: failed to reach /json/version within 5 seconds: %w", err)
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ body, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, err
+ }
+
+ // Firefox returns HTML (not JSON) at /json/version; ignore parse errors.
+ // Use WebSocketDebuggerUrl if present, otherwise fall back to /session.
+ var ver browserVersion
+ _ = json.Unmarshal(body, &ver)
+
+ var wsLoc *url.URL
+ if ver.WebSocketDebuggerUrl != "" {
+ wsLoc, err = url.Parse(ver.WebSocketDebuggerUrl)
+ if err != nil {
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, err
+ }
+ } else {
+ wsLoc = &url.URL{
+ Scheme: "ws",
+ Host: fmt.Sprintf("127.0.0.1:%d", debugPort),
+ Path: "/session",
+ }
+ }
+
+ f.bidi, err = newBidiConn(wsLoc)
+ if err != nil {
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, err
+ }
+
+ // Activate WebDriver BiDi session.
+ if err := f.bidi.Send(h{"id": 0, "method": "session.new", "params": h{"capabilities": h{}}}); err != nil {
+ f.bidi.Close()
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, err
+ }
+ for {
+ var m struct {
+ ID int `json:"id"`
+ }
+ if err := f.bidi.Receive(&m); err != nil {
+ f.bidi.Close()
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, fmt.Errorf("firefox: waiting for session.new response: %w", err)
+ }
+ if m.ID == 0 {
+ break
+ }
+ }
+
+ // Get the initial browsing context ID.
+ if err := f.bidi.Send(h{"id": 1, "method": "browsingContext.getTree", "params": h{}}); err != nil {
+ f.bidi.Close()
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, err
+ }
+ for {
+ var m struct {
+ ID int `json:"id"`
+ Result struct {
+ Contexts []struct {
+ Context string `json:"context"`
+ } `json:"contexts"`
+ } `json:"result"`
+ }
+ if err := f.bidi.Receive(&m); err != nil {
+ f.bidi.Close()
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, fmt.Errorf("firefox: waiting for browsingContext.getTree response: %w", err)
+ }
+ if m.ID == 1 {
+ if len(m.Result.Contexts) == 0 {
+ f.bidi.Close()
+ killProcessTree(f.cmd.Process.Pid)
+ return nil, errors.New("firefox: no browsing contexts found")
+ }
+ f.context = m.Result.Contexts[0].Context
+ break
+ }
+ }
+
+ f.doneC = make(chan struct{})
+ f.watchdogDoneC = make(chan struct{})
+ go func() {
+ // On Windows, firefox.exe is a launcher stub that exits immediately.
+ // doneC is closed by readLoop (BiDi drop), not here.
+ err := f.cmd.Wait()
+ log.Printf("lorca/firefox: launcher process exited err=%v state=%v", err, f.cmd.ProcessState)
+ }()
+ go f.readLoop()
+ go f.contextWatchdog()
+
+ // Subscribe to events needed for tab management, script injection, and nav blocking.
+ if _, err := f.send("session.subscribe", h{"events": []string{
+ "log.entryAdded",
+ "browsingContext.navigationStarted",
+ "browsingContext.load",
+ "browsingContext.contextCreated",
+ "browsingContext.contextDestroyed",
+ "script.realmCreated",
+ }}); err != nil {
+ log.Printf("lorca/firefox: session.subscribe failed: %v", err)
+ }
+
+ // Apply the host executable's icon to Firefox's window on platforms that
+ // support it. Runs in a background goroutine to avoid blocking startup.
+ go applyFirefoxWindowIcon(f.cmd.Process.Pid, iconPath)
+
+ return f, nil
+}
+
+func (f *firefox) send(method string, params h) (json.RawMessage, error) {
+ id := int(atomic.AddInt32(&f.id, 1))
+ resc := make(chan result, 1)
+ f.Lock()
+ f.pending[id] = resc
+ f.Unlock()
+
+ err := f.bidi.Send(h{"id": id, "method": method, "params": params})
+ if err != nil {
+ f.Lock()
+ delete(f.pending, id)
+ f.Unlock()
+ return nil, err
+ }
+ res := <-resc
+ return res.Value, res.Err
+}
+
+// sendNoWait sends a BiDi command without waiting for a response (response is discarded).
+// Safe to call from within readLoop because bidiConn.Send uses a separate write mutex.
+func (f *firefox) sendNoWait(method string, params h) {
+ id := int(atomic.AddInt32(&f.id, 1))
+ // Intentionally no f.pending entry -response is dropped.
+ _ = f.bidi.Send(h{"id": id, "method": method, "params": params})
+}
+
+// bidiMsg is every message Firefox sends over BiDi. Error responses use a top-level
+// "error" string code and a separate "message" string (not a nested object).
+type bidiMsg struct {
+ ID int `json:"id"`
+ Result json.RawMessage `json:"result"`
+ Error string `json:"error"` // BiDi error code string, e.g. "unknown error"
+ Message string `json:"message"` // BiDi error message string
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+}
+
+func (f *firefox) readLoop() {
+ defer f.closeDone()
+ for {
+ var m bidiMsg
+ if err := f.bidi.Receive(&m); err != nil {
+ log.Printf("lorca/firefox: readLoop exiting: %v", err)
+ return
+ }
+ if m.Method != "" {
+ switch m.Method {
+ case "browsingContext.contextCreated":
+ // lorca is single-page: close any new top-level context immediately.
+ // Two-pronged: sendNoWait for speed, then getTree in a goroutine in
+ // case Firefox replaced the context ID before our close arrived.
+ var ctxParams struct {
+ Context string `json:"context"`
+ Parent string `json:"parent"`
+ }
+ if err := json.Unmarshal(m.Params, &ctxParams); err == nil {
+ f.Lock()
+ mainCtx := f.context
+ f.Unlock()
+ if ctxParams.Parent == "" && ctxParams.Context != mainCtx {
+ // contextCreated is firing reliably — the watchdog is no longer needed.
+ f.stopWatchdog()
+ f.sendNoWait("browsingContext.close", h{
+ "context": ctxParams.Context,
+ "promptUnload": false,
+ })
+ go func(main string) {
+ f.closeStrayContexts(main)
+ // Re-activate main context; without this Firefox may leave the content area blank.
+ if _, err := f.send("browsingContext.activate", h{"context": main}); err != nil {
+ log.Printf("lorca/firefox: activate main context: %v (falling back to window.focus)", err)
+ f.sendNoWait("script.evaluate", h{
+ "expression": "window.focus(); void 0",
+ "awaitPromise": false,
+ "target": h{"context": main},
+ "resultOwnership": "none",
+ })
+ }
+ }(mainCtx)
+ }
+ }
+ case "browsingContext.contextDestroyed":
+ params := struct {
+ Context string `json:"context"`
+ }{}
+ json.Unmarshal(m.Params, ¶ms)
+ if params.Context == f.context {
+ log.Printf("lorca/firefox: main context destroyed - killing")
+ f.kill()
+ return
+ }
+ // Stray context destroyed; re-activate main so it gets focus.
+ go func() {
+ f.Lock()
+ main := f.context
+ f.Unlock()
+ if _, err := f.send("browsingContext.activate", h{"context": main}); err != nil {
+ f.sendNoWait("script.evaluate", h{
+ "expression": "window.focus(); void 0",
+ "awaitPromise": false,
+ "target": h{"context": main},
+ "resultOwnership": "none",
+ })
+ }
+ }()
+ case "log.entryAdded":
+ // console output from the page - silently ignored
+ case "browsingContext.navigationStarted":
+ var navParams struct {
+ Context string `json:"context"`
+ URL string `json:"url"`
+ }
+ if err := json.Unmarshal(m.Params, &navParams); err == nil {
+ f.Lock()
+ appURL := f.appURL
+ blockBackNav := f.blockBackNav
+ f.Unlock()
+ // If back-nav blocking is enabled and a navigation away from the
+ // app URL is detected (e.g. the user pressed Back), redirect back.
+ if blockBackNav && appURL != "" && navParams.Context == f.context && !strings.HasPrefix(navParams.URL, appURL) {
+ go func(redirectURL string) {
+ if _, err := f.send("browsingContext.navigate", h{
+ "url": redirectURL,
+ "context": f.context,
+ "wait": "none",
+ }); err != nil {
+ log.Printf("lorca/firefox: redirect to appURL failed: %v", err)
+ }
+ }(appURL)
+ }
+ }
+ case "browsingContext.load":
+ // Re-eval all loadScripts in the page realm as a single call (belt-and-suspenders
+ // after realmCreated). Goroutine required: f.eval blocks on readLoop response.
+ var loadParams struct {
+ Context string `json:"context"`
+ }
+ json.Unmarshal(m.Params, &loadParams)
+ if loadParams.Context == f.context {
+ f.Lock()
+ scripts := append([]string(nil), f.loadScripts...)
+ f.Unlock()
+ if len(scripts) > 0 {
+ go func(scripts []string) {
+ if _, err := f.eval(strings.Join(scripts, ";\n")); err != nil {
+ log.Printf("lorca/firefox: post-load eval error: %v", err)
+ }
+ }(scripts)
+ }
+ }
+ case "script.realmCreated":
+ // When the real-origin window realm is created, immediately fire all
+ // loadScripts via sendNoWait to win the race against page scripts.
+ // Only targets the page realm (sandbox=="" guard protects against future
+ // addPreloadScript use). browsingContext.load is the unconditional fallback.
+ var realmParams struct {
+ Realm string `json:"realm"`
+ Origin string `json:"origin"`
+ Context string `json:"context"`
+ Type string `json:"type"`
+ Sandbox string `json:"sandbox"`
+ }
+ if err := json.Unmarshal(m.Params, &realmParams); err == nil &&
+ realmParams.Sandbox == "" &&
+ realmParams.Context == f.context &&
+ realmParams.Type == "window" &&
+ realmParams.Origin != "" && realmParams.Origin != "null" {
+ f.Lock()
+ scripts := append([]string(nil), f.loadScripts...)
+ f.Unlock()
+ if len(scripts) > 0 {
+ f.sendNoWait("script.evaluate", h{
+ "expression": strings.Join(scripts, ";\n"),
+ "awaitPromise": false,
+ "target": h{"context": f.context},
+ "resultOwnership": "none",
+ })
+ }
+ }
+ }
+ continue
+ }
+ // Response -route to pending channel.
+ f.Lock()
+ ch, ok := f.pending[m.ID]
+ delete(f.pending, m.ID)
+ f.Unlock()
+ if !ok {
+ continue
+ }
+ if m.Error != "" {
+ msg := m.Message
+ if msg == "" {
+ msg = m.Error
+ }
+ log.Printf("lorca/firefox: BiDi error id=%d error=%q message=%q", m.ID, m.Error, m.Message)
+ ch <- result{Err: errors.New(msg)}
+ } else {
+ ch <- result{Value: m.Result}
+ }
+ }
+}
+
+func (f *firefox) eval(expr string) (json.RawMessage, error) {
+ raw, err := f.send("script.evaluate", h{
+ "expression": expr,
+ "awaitPromise": true,
+ "target": h{"context": f.context},
+ "resultOwnership": "root",
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ var evalResult struct {
+ Type string `json:"type"`
+ Result json.RawMessage `json:"result"`
+ ExceptionDetails struct {
+ Text string `json:"text"`
+ Exception struct {
+ Type string `json:"type"`
+ Value json.RawMessage `json:"value"`
+ Description string `json:"description"`
+ } `json:"exception"`
+ } `json:"exceptionDetails"`
+ }
+ if err := json.Unmarshal(raw, &evalResult); err != nil {
+ return nil, err
+ }
+ if evalResult.Type == "exception" {
+ ex := evalResult.ExceptionDetails.Exception
+ if len(ex.Value) > 0 {
+ if unwrapped, err2 := bidiValueToJSON(ex.Value); err2 == nil {
+ return nil, errors.New(string(unwrapped))
+ }
+ return nil, errors.New(string(ex.Value))
+ }
+ if ex.Description != "" {
+ return nil, errors.New(ex.Description)
+ }
+ return nil, errors.New(evalResult.ExceptionDetails.Text)
+ }
+ if len(evalResult.Result) == 0 {
+ return json.RawMessage("null"), nil
+ }
+ return bidiValueToJSON(evalResult.Result)
+}
+
+func (f *firefox) load(url string) error {
+ _, err := f.send("browsingContext.navigate", h{
+ "url": url,
+ "context": f.context,
+ "wait": "none",
+ })
+ if err == nil {
+ f.Lock()
+ f.appURL = url
+ f.Unlock()
+ }
+ return err
+}
+
+func (f *firefox) setBlockBackNavigation(enable bool) {
+ f.Lock()
+ f.blockBackNav = enable
+ f.Unlock()
+}
+
+// stopWatchdog signals the contextWatchdog goroutine to exit (idempotent via sync.Once).
+func (f *firefox) stopWatchdog() {
+ f.watchdogOnce.Do(func() { close(f.watchdogDoneC) })
+}
+
+// contextWatchdog polls getTree to close stray contexts. It is a fallback for a
+// Firefox BiDi bug where contextCreated is not fired for the first Ctrl+T/Ctrl+N tab.
+// Once a stray is found, contextCreated becomes reliable and the watchdog stops itself.
+func (f *firefox) contextWatchdog() {
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-f.doneC:
+ return
+ case <-f.watchdogDoneC:
+ return
+ case <-ticker.C:
+ f.Lock()
+ main := f.context
+ f.Unlock()
+ if f.closeStrayContexts(main) {
+ // A stray was found and closed; contextCreated is now reliable.
+ f.stopWatchdog()
+ return
+ }
+ }
+ }
+}
+
+// closeStrayContexts closes every top-level context that is not mainCtx, returning
+// true if at least one was closed. Uses getTree to avoid "no such frame" races.
+func (f *firefox) closeStrayContexts(mainCtx string) bool {
+ raw, err := f.send("browsingContext.getTree", h{})
+ if err != nil {
+ return false
+ }
+ var tree struct {
+ Contexts []struct {
+ Context string `json:"context"`
+ } `json:"contexts"`
+ }
+ if err := json.Unmarshal(raw, &tree); err != nil {
+ return false
+ }
+ closed := false
+ for _, ctx := range tree.Contexts {
+ if ctx.Context == mainCtx {
+ continue
+ }
+ if _, err := f.send("browsingContext.close", h{
+ "context": ctx.Context,
+ "promptUnload": false,
+ }); err != nil {
+ log.Printf("lorca/firefox: closeStrayContexts: close %s failed: %v", ctx.Context, err)
+ } else {
+ closed = true
+ }
+ }
+ return closed
+}
+
+func (f *firefox) setAppUserModelID(id string) {
+ go applyFirefoxWindowAUMID(f.cmd.Process.Pid, id)
+}
+
+func (f *firefox) injectScript(js string) error {
+ // Scripts must run in the page window realm, not a BiDi preload sandbox realm.
+ // Preload sandbox objects trigger Firefox Xray "Permission denied to access property
+ // 'length'" when Vue's reactivity system introspects them. Store in loadScripts
+ // instead; realmCreated and browsingContext.load re-eval it in the page realm.
+ f.Lock()
+ // Prepend so bootstrap runs before binding scripts that depend on window.__lorcaWS.
+ f.loadScripts = append([]string{js}, f.loadScripts...)
+ f.Unlock()
+ _, err := f.eval(js)
+ if err != nil {
+ log.Printf("lorca/firefox injectScript eval error: %v", err)
+ }
+ return err
+}
+
+func (f *firefox) injectBinding(name string) error {
+ code := bindingScript(name)
+
+ // Same page-realm constraint as injectScript: store in loadScripts so
+ // realmCreated and browsingContext.load install it on every navigation.
+ f.Lock()
+ f.loadScripts = append(f.loadScripts, code)
+ f.Unlock()
+
+ // Fire-and-forget eval on current page so the binding is available immediately.
+ // Non-blocking (sendNoWait) because 50+ bindings at startup would add >10s of lag.
+ f.sendNoWait("script.evaluate", h{
+ "expression": code,
+ "awaitPromise": false,
+ "target": h{"context": f.context},
+ "resultOwnership": "none",
+ })
+ return nil
+}
+
+func (f *firefox) setBounds(b Bounds) error {
+ if b.Left != 0 {
+ log.Printf("lorca/firefox: SetBounds Left=%d not supported", b.Left)
+ }
+ if b.Top != 0 {
+ log.Printf("lorca/firefox: SetBounds Top=%d not supported", b.Top)
+ }
+ if b.WindowState != "" && b.WindowState != WindowStateNormal {
+ log.Printf("lorca/firefox: SetBounds WindowState=%q not supported", b.WindowState)
+ }
+ if b.Width > 0 || b.Height > 0 {
+ // window.resizeTo sets the outer window dimensions without locking the
+ // viewport (unlike browsingContext.setViewport which prevents resizing).
+ if _, err := f.eval(fmt.Sprintf("window.resizeTo(%d,%d)", b.Width, b.Height)); err != nil {
+ return err
+ }
+ f.Lock()
+ f.lastBounds.Width = b.Width
+ f.lastBounds.Height = b.Height
+ f.Unlock()
+ }
+ return nil
+}
+
+func (f *firefox) bounds() (Bounds, error) {
+ raw, err := f.eval("[window.innerWidth, window.innerHeight]")
+ if err != nil {
+ return Bounds{}, err
+ }
+ var dims [2]int
+ if err := json.Unmarshal(raw, &dims); err != nil {
+ return Bounds{}, err
+ }
+ return Bounds{Width: dims[0], Height: dims[1]}, nil
+}
+
+func (f *firefox) kill() {
+ log.Printf("lorca/firefox: kill() called")
+ if f.bidi != nil {
+ f.sendNoWait("browser.close", h{})
+ time.Sleep(150 * time.Millisecond) // brief window for graceful exit
+ f.bidi.Close()
+ }
+ f.Lock()
+ for _, ch := range f.pending {
+ ch <- result{Err: errors.New("firefox closed")}
+ }
+ f.pending = map[int]chan result{}
+ f.Unlock()
+
+ killFirefoxProcessTree(f.cmd.Process.Pid, f.cmd.ProcessState)
+}
+
+func (f *firefox) done() <-chan struct{} { return f.doneC }
diff --git a/firefox_nonwindows.go b/firefox_nonwindows.go
new file mode 100644
index 0000000..58b8e3a
--- /dev/null
+++ b/firefox_nonwindows.go
@@ -0,0 +1,21 @@
+//go:build !windows
+
+package lorca
+
+import "os"
+
+// killFirefoxProcessTree kills the Firefox launcher process. On Windows, the real
+// implementation also kills orphaned child processes left by the launcher stub.
+func killFirefoxProcessTree(pid int, state *os.ProcessState) {
+ if state == nil || !state.Exited() {
+ killProcessTree(pid)
+ }
+}
+
+func applyFirefoxWindowIcon(_ int, _ string) {
+ // applyFirefoxWindowIcon is a no-op on non-Windows platforms.
+}
+
+func applyFirefoxWindowAUMID(_ int, _ string) {
+ // applyFirefoxWindowAUMID is a no-op on non-Windows platforms.
+}
diff --git a/firefox_test.go b/firefox_test.go
new file mode 100644
index 0000000..089c937
--- /dev/null
+++ b/firefox_test.go
@@ -0,0 +1,450 @@
+package lorca
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func skipIfNoFirefox(t *testing.T) string {
+ t.Helper()
+ path, ok := os.LookupEnv("LORCAFIREFOX")
+ if !ok || path == "" {
+ t.Skip("LORCAFIREFOX not set; skipping Firefox integration tests")
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Skipf("LORCAFIREFOX=%s not found; skipping Firefox integration tests", path)
+ }
+ return path
+}
+
+func TestBidiValueToJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"string", `{"type":"string","value":"hello"}`, `"hello"`},
+ {"number int", `{"type":"number","value":42}`, `42`},
+ {"number float", `{"type":"number","value":3.14}`, `3.14`},
+ {"boolean true", `{"type":"boolean","value":true}`, `true`},
+ {"boolean false", `{"type":"boolean","value":false}`, `false`},
+ {"null", `{"type":"null"}`, `null`},
+ {"undefined", `{"type":"undefined"}`, `null`},
+ {"array", `{"type":"array","value":[{"type":"string","value":"a"},{"type":"number","value":1}]}`, `["a",1]`},
+ {"object", `{"type":"object","value":[["key",{"type":"string","value":"val"}]]}`, `{"key":"val"}`},
+ {"nested", `{"type":"array","value":[{"type":"object","value":[["x",{"type":"number","value":1}]]}]}`, `[{"x":1}]`},
+ {"unknown type", `{"type":"symbol"}`, `null`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := bidiValueToJSON(json.RawMessage(tt.input))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(result) != tt.expected {
+ t.Fatalf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestFirefoxNew(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.kill()
+ select {
+ case <-f.done():
+ case <-time.After(5 * time.Second):
+ t.Fatal("done() did not close after kill")
+ }
+}
+
+func TestFirefoxEval(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ for _, tc := range []struct {
+ expr string
+ result string
+ errMsg string
+ }{
+ {expr: `42`, result: `42`},
+ {expr: `"hello"`, result: `"hello"`},
+ {expr: `2+3`, result: `5`},
+ {expr: `[1,2,3]`, result: `[1,2,3]`},
+ {expr: `({x:1,y:2})`, result: `{"x":1,"y":2}`},
+ {expr: `Promise.resolve(7)`, result: `7`},
+ {expr: `throw "fail"`, errMsg: `"fail"`},
+ } {
+ result, err := f.eval(tc.expr)
+ if tc.errMsg != "" {
+ if err == nil || err.Error() != tc.errMsg {
+ t.Fatalf("%s: expected error %q, got %v", tc.expr, tc.errMsg, err)
+ }
+ } else if err != nil {
+ t.Fatalf("%s: unexpected error: %v", tc.expr, err)
+ } else if string(result) != tc.result {
+ t.Fatalf("%s: expected %s, got %s", tc.expr, tc.result, string(result))
+ }
+ }
+}
+
+func TestFirefoxLoad(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ if err := f.load("data:text/html,Hello"); err != nil {
+ t.Fatal(err)
+ }
+ result, err := f.eval(`document.body.innerText`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(result) != `"Hello"` {
+ t.Fatalf("expected %q, got %s", "Hello", result)
+ }
+}
+
+func TestFirefoxInjectScript(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ if err := f.injectScript(`window.__injected = 99`); err != nil {
+ t.Fatal(err)
+ }
+ result, err := f.eval(`window.__injected`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(result) != `99` {
+ t.Fatalf("expected 99, got %s", result)
+ }
+}
+
+func TestFirefoxBounds(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir, "--window-size=800,600")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ b, err := f.bounds()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.Width == 0 || b.Height == 0 {
+ t.Fatalf("expected non-zero bounds, got %+v", b)
+ }
+}
+
+func TestFirefoxSetBounds(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ dir := t.TempDir()
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ if err := f.setBounds(Bounds{Width: 1024, Height: 768}); err != nil {
+ t.Fatal(err)
+ }
+ b, err := f.bounds()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.Width != 1024 || b.Height != 768 {
+ t.Fatalf("expected 1024x768, got %dx%d", b.Width, b.Height)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure-Go structural tests -no browser required
+// ---------------------------------------------------------------------------
+
+// TestBindingScriptInvariants checks properties of the JS produced by
+// bindingScript without starting a browser. Three invariants must hold:
+//
+// 1. No double quotes in the output -the code is embedded as the argument to
+// window.eval("...") inside the preload functionDeclaration string; a double
+// quote would close the outer JS string and produce a syntax error.
+//
+// 2. Binding is created as a plain function expression, not via new Function -
+// new Function called from the preload sandbox produces a sandbox-realm
+// function, which causes "Permission denied to access property 'length'"
+// when page code (Vue, WebSocket internals) tries to call it.
+//
+// 3. The function body references window.__lorcaPending and window.__lorcaSend,
+// the page-realm state set up by the bootstrap, so calls are routed through
+// the relay.
+func TestBindingScriptInvariants(t *testing.T) {
+ for _, name := range []string{"add", "getPlayerData", "myBinding123", "x"} {
+ t.Run(name, func(t *testing.T) {
+ code := bindingScript(name)
+
+ if strings.Contains(code, `"`) {
+ t.Errorf("output contains double quotes; must be safely embeddable "+
+ "inside window.eval(\"...\") without escaping:\n%s", code)
+ }
+
+ want := "window['" + name + "'] = function()"
+ if !strings.Contains(code, want) {
+ t.Errorf("expected pattern %q in output:\n%s", want, code)
+ }
+
+ if strings.Contains(code, "new window.Function") || strings.Contains(code, "new Function(") {
+ t.Errorf("output must not use new Function (creates sandbox-realm function):\n%s", code)
+ }
+
+ if !strings.Contains(code, "window.__lorcaPending") {
+ t.Errorf("output does not reference window.__lorcaPending:\n%s", code)
+ }
+ // window.__lorcaSend has been intentionally removed: on Firefox, a
+ // sandbox-realm function assigned to window causes
+ // "Permission denied to access property 'length'" when page-realm
+ // code (e.g. Vue) introspects it via Xray. bindingScript inlines
+ // the send using window.__lorcaWS.send() / window.__lorcaQueue.push()
+ // instead (method calls on Xray-wrapped objects are permitted).
+ if strings.Contains(code, "window.__lorcaSend") {
+ t.Errorf("output must NOT reference window.__lorcaSend (sandbox-realm function):\n%s", code)
+ }
+ if !strings.Contains(code, "window.__lorcaWS.send") {
+ t.Errorf("output does not reference window.__lorcaWS.send:\n%s", code)
+ }
+ if !strings.Contains(code, "window.__lorcaQueue.push") {
+ t.Errorf("output does not reference window.__lorcaQueue.push:\n%s", code)
+ }
+
+ // Wrapping in window.eval("...") must add exactly two double quotes -
+ // the delimiters -and no others from the code itself.
+ wrapped := `window.eval("` + code + `")`
+ if n := strings.Count(wrapped, `"`); n != 2 {
+ t.Errorf("wrapped code has %d double quotes, want 2 (the window.eval delimiters only)", n)
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Firefox integration tests -require LORCAFIREFOX env var
+// ---------------------------------------------------------------------------
+
+// TestFirefoxBootstrapFunctionsPageRealm verifies that the bootstrap's onopen
+// and onmessage handlers are page-realm functions after a navigation (when the
+// preload fires). It checks that .length is readable on each handler from
+// page-realm code (script.evaluate). When functions are sandbox-realm,
+// Firefox's Xray wrapper throws "Permission denied to access property 'length'"
+// whenever page code inspects them.
+func TestFirefoxBootstrapFunctionsPageRealm(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ if err := f.injectScript(r.bootstrapScript()); err != nil {
+ t.Fatal(err)
+ }
+ // Navigate to a data: URL so the bootstrap's protocol guard allows the script to run.
+ if err := f.load("data:text/html,"); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, c := range []struct {
+ expr string
+ want string
+ desc string
+ }{
+ {`typeof window.__lorcaWS`, `"object"`, "__lorcaWS must be set"},
+ {`typeof window.__lorcaPending`, `"object"`, "__lorcaPending must be set"},
+ {`typeof window.__lorcaQueue`, `"object"`, "__lorcaQueue must be set"},
+ {`typeof window.__lorcaWS.onopen`, `"function"`, "onopen must be set"},
+ {`typeof window.__lorcaWS.onmessage`, `"function"`, "onmessage must be set"},
+ // .length access: page-realm functions expose it; sandbox-realm functions
+ // throw "Permission denied" when page code accesses .length via Xray.
+ {`typeof window.__lorcaWS.onopen.length`, `"number"`, "onopen.length readable (page-realm)"},
+ {`typeof window.__lorcaWS.onmessage.length`, `"number"`, "onmessage.length readable (page-realm)"},
+ } {
+ result, err := f.eval(c.expr)
+ if err != nil {
+ t.Fatalf("%s: eval(%q) error (Permission Denied indicates sandbox-realm function): %v",
+ c.desc, c.expr, err)
+ }
+ if string(result) != c.want {
+ t.Fatalf("%s: eval(%q) = %s, want %s", c.desc, c.expr, result, c.want)
+ }
+ }
+}
+
+// TestFirefoxBindingScriptPageRealm verifies that injectBinding creates
+// window['name'] as a page-realm function. The .length check mirrors what
+// Firefox's WebSocket event dispatch and Vue's call sites do before invoking
+// the function; sandbox-realm functions throw "Permission denied" at that point.
+func TestFirefoxBindingScriptPageRealm(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ f, err := newFirefoxWithArgs(binary, "", "--headless", "--profile", t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { f.kill(); <-f.done() }()
+
+ if err := f.injectScript(r.bootstrapScript()); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.injectBinding("testBinding"); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.load("data:text/html,"); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := f.eval(`typeof window.testBinding.length`)
+ if err != nil {
+ t.Fatalf("window.testBinding.length threw (indicates sandbox-realm function, not page-realm): %v", err)
+ }
+ if string(result) != `"number"` {
+ t.Fatalf("expected testBinding.length to be a number, got typeof=%s", result)
+ }
+}
+
+// TestFirefoxBindingBasic is an end-to-end test for a single Go->JS->Go binding
+// call under Firefox. This is the scenario that silently hangs when bindings
+// land in sandbox realm: the JS Promise never resolves, Eval never returns.
+func TestFirefoxBindingBasic(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ ui, err := NewWithBrowser("data:text/html,", t.TempDir(),
+ binary, 480, 320, BrowserFirefox, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+
+ if err := ui.Bind("add", func(a, b int) int { return a + b }); err != nil {
+ t.Fatal(err)
+ }
+ v := ui.Eval(`add(2, 3)`)
+ if v.Err() != nil {
+ t.Fatalf("binding call failed: %v", v.Err())
+ }
+ if v.Int() != 5 {
+ t.Fatalf("expected 5, got %d", v.Int())
+ }
+}
+
+// TestFirefoxBindingMultiple verifies that several bindings registered on the
+// same UI instance all work -each gets its own preload script and its own
+// entry in the relay dispatch table.
+func TestFirefoxBindingMultiple(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ ui, err := NewWithBrowser("data:text/html,", t.TempDir(),
+ binary, 480, 320, BrowserFirefox, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+
+ if err := ui.Bind("double", func(n int) int { return n * 2 }); err != nil {
+ t.Fatal(err)
+ }
+ if err := ui.Bind("sum", func(a, b, c int) int { return a + b + c }); err != nil {
+ t.Fatal(err)
+ }
+ if err := ui.Bind("neg", func(n int) int { return -n }); err != nil {
+ t.Fatal(err)
+ }
+
+ if v := ui.Eval(`double(7)`); v.Err() != nil || v.Int() != 14 {
+ t.Fatalf("double(7): got %d, err %v", v.Int(), v.Err())
+ }
+ if v := ui.Eval(`sum(1, 2, 3)`); v.Err() != nil || v.Int() != 6 {
+ t.Fatalf("sum(1,2,3): got %d, err %v", v.Int(), v.Err())
+ }
+ if v := ui.Eval(`neg(5)`); v.Err() != nil || v.Int() != -5 {
+ t.Fatalf("neg(5): got %d, err %v", v.Int(), v.Err())
+ }
+}
+
+// TestFirefoxBindingAfterNavigation verifies that bindings registered before a
+// page navigation are re-established on the new document via the per-binding
+// preload script, without needing another Bind call from Go.
+func TestFirefoxBindingAfterNavigation(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ ui, err := NewWithBrowser("data:text/html,page1", t.TempDir(),
+ binary, 480, 320, BrowserFirefox, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+
+ if err := ui.Bind("mul", func(a, b int) int { return a * b }); err != nil {
+ t.Fatal(err)
+ }
+ if v := ui.Eval(`mul(3, 4)`); v.Err() != nil || v.Int() != 12 {
+ t.Fatalf("before nav: mul(3,4) = %d, err %v", v.Int(), v.Err())
+ }
+
+ if err := ui.Load("data:text/html,page2"); err != nil {
+ t.Fatal(err)
+ }
+
+ if v := ui.Eval(`mul(5, 6)`); v.Err() != nil || v.Int() != 30 {
+ t.Fatalf("after nav: mul(5,6) = %d, err %v", v.Int(), v.Err())
+ }
+}
+
+// TestFirefoxBindingError verifies that when a Go binding returns an error the
+// JS Promise is rejected and ui.Eval surfaces that error.
+func TestFirefoxBindingError(t *testing.T) {
+ binary := skipIfNoFirefox(t)
+ ui, err := NewWithBrowser("data:text/html,", t.TempDir(),
+ binary, 480, 320, BrowserFirefox, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+
+ if err := ui.Bind("fail", func() error { return errors.New("binding error") }); err != nil {
+ t.Fatal(err)
+ }
+ if v := ui.Eval(`fail()`); v.Err() == nil {
+ t.Fatal("expected error from failing binding, got nil")
+ }
+}
diff --git a/firefox_windows.go b/firefox_windows.go
new file mode 100644
index 0000000..1972db3
--- /dev/null
+++ b/firefox_windows.go
@@ -0,0 +1,316 @@
+//go:build windows
+
+package lorca
+
+import (
+ "log"
+ "os"
+ "runtime"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+var (
+ fxwUser32 = syscall.NewLazyDLL("user32.dll")
+ fxwKernel32 = syscall.NewLazyDLL("kernel32.dll")
+ fxwShell32 = syscall.NewLazyDLL("shell32.dll")
+ fxwAdvapi32 = syscall.NewLazyDLL("advapi32.dll")
+
+ fxwEnumWindows = fxwUser32.NewProc("EnumWindows")
+ fxwGetWindowThreadProcessId = fxwUser32.NewProc("GetWindowThreadProcessId")
+ fxwSendMessageW = fxwUser32.NewProc("SendMessageW")
+ fxwIsWindowVisible = fxwUser32.NewProc("IsWindowVisible")
+ fxwLoadImageW = fxwUser32.NewProc("LoadImageW")
+ fxwGetModuleHandleW = fxwKernel32.NewProc("GetModuleHandleW")
+ fxwCreateToolhelp32Snapshot = fxwKernel32.NewProc("CreateToolhelp32Snapshot")
+ fxwProcess32FirstW = fxwKernel32.NewProc("Process32FirstW")
+ fxwProcess32NextW = fxwKernel32.NewProc("Process32NextW")
+ fxwCloseHandle = fxwKernel32.NewProc("CloseHandle")
+ fxwSHGetPropertyStoreForWindow = fxwShell32.NewProc("SHGetPropertyStoreForWindow")
+
+ fxwRegCreateKeyExW = fxwAdvapi32.NewProc("RegCreateKeyExW")
+ fxwRegSetValueExW = fxwAdvapi32.NewProc("RegSetValueExW")
+ fxwRegCloseKey = fxwAdvapi32.NewProc("RegCloseKey")
+)
+
+const (
+ fxwWMSetIcon = uintptr(0x0080)
+ fxwIconSmall = uintptr(0)
+ fxwIconBig = uintptr(1)
+ fxwImageIcon = uintptr(1)
+ fxwLRDefaultSize = uintptr(0x0040)
+ fxwLRLoadFromFile = uintptr(0x0010)
+ fxwLRShared = uintptr(0x8000)
+ fxwTH32CSSnapProcess = uintptr(0x00000002)
+ fxwInvalidHandle = ^uintptr(0)
+)
+
+// fxwIPropertyStore is an IPropertyStore COM object; vtable pointer avoids uintptr->unsafe.Pointer that go vet flags.
+type fxwIPropertyStore struct{ vtbl *[8]uintptr }
+
+// fxwProcessEntry32W mirrors PROCESSENTRY32W; uintptr aligns to native size (4 on 32-bit, 8 on 64-bit).
+type fxwProcessEntry32W struct {
+ dwSize uint32
+ cntUsage uint32
+ th32ProcessID uint32
+ th32DefaultHeapID uintptr // ULONG_PTR - 4 bytes on 32-bit, 8 on 64-bit
+ th32ModuleID uint32
+ cntThreads uint32
+ th32ParentProcessID uint32
+ pcPriClassBase int32
+ dwFlags uint32
+ szExeFile [260]uint16
+}
+
+// fxwDescendantPIDs returns launcherPID and all descendant PIDs. On Windows,
+// Firefox's launcher stub exits immediately; children retain the stub PID as parent.
+func fxwDescendantPIDs(launcherPID int) map[uint32]bool {
+ snap, _, _ := fxwCreateToolhelp32Snapshot.Call(fxwTH32CSSnapProcess, 0)
+ if snap == fxwInvalidHandle {
+ return nil
+ }
+ defer fxwCloseHandle.Call(snap)
+
+ children := make(map[uint32][]uint32)
+ var e fxwProcessEntry32W
+ e.dwSize = uint32(unsafe.Sizeof(e))
+ ret, _, _ := fxwProcess32FirstW.Call(snap, uintptr(unsafe.Pointer(&e)))
+ for ret != 0 {
+ children[e.th32ParentProcessID] = append(children[e.th32ParentProcessID], e.th32ProcessID)
+ e.dwSize = uint32(unsafe.Sizeof(e))
+ ret, _, _ = fxwProcess32NextW.Call(snap, uintptr(unsafe.Pointer(&e)))
+ }
+
+ result := map[uint32]bool{uint32(launcherPID): true}
+ queue := []uint32{uint32(launcherPID)}
+ for len(queue) > 0 {
+ cur := queue[0]
+ queue = queue[1:]
+ for _, child := range children[cur] {
+ if !result[child] {
+ result[child] = true
+ queue = append(queue, child)
+ }
+ }
+ }
+ return result
+}
+
+// killFirefoxProcessTree kills launcherPID and all descendants, including orphans.
+func killFirefoxProcessTree(launcherPID int, state *os.ProcessState) {
+ pids := fxwDescendantPIDs(launcherPID)
+ for childPID := range pids {
+ if int(childPID) == launcherPID {
+ continue
+ }
+ _ = killProcessTree(int(childPID))
+ }
+ if state == nil || !state.Exited() {
+ _ = killProcessTree(launcherPID)
+ }
+}
+
+// applyFirefoxWindowIcon sets WM_SETICON on all visible top-level Firefox windows.
+// iconPath is a .ico file path; falls back to PE resource 1 of the host executable.
+// Called from a background goroutine; sleeps 500ms to wait for the window to appear.
+func applyFirefoxWindowIcon(launcherPID int, iconPath string) {
+ time.Sleep(500 * time.Millisecond)
+
+ var hIcon uintptr
+ if iconPath != "" {
+ pathPtr, err := syscall.UTF16PtrFromString(iconPath)
+ if err == nil {
+ hIcon, _, _ = fxwLoadImageW.Call(0, uintptr(unsafe.Pointer(pathPtr)), fxwImageIcon, 0, 0, fxwLRDefaultSize|fxwLRLoadFromFile)
+ }
+ }
+ if hIcon == 0 {
+ // Fallback: PE icon resource 1 from the host executable.
+ hInst, _, _ := fxwGetModuleHandleW.Call(0)
+ hIcon, _, _ = fxwLoadImageW.Call(hInst, 1, fxwImageIcon, 0, 0, fxwLRDefaultSize|fxwLRShared)
+ }
+ if hIcon == 0 {
+ log.Printf("lorca/firefox: applyWindowIcon: could not load icon (path=%q)", iconPath)
+ return
+ }
+
+ pids := fxwDescendantPIDs(launcherPID)
+ if len(pids) == 0 {
+ return
+ }
+
+ count := 0
+ cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr {
+ var pid uint32
+ fxwGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
+ if !pids[pid] {
+ return 1
+ }
+ visible, _, _ := fxwIsWindowVisible.Call(hwnd)
+ if visible == 0 {
+ return 1
+ }
+ fxwSendMessageW.Call(hwnd, fxwWMSetIcon, fxwIconSmall, hIcon)
+ fxwSendMessageW.Call(hwnd, fxwWMSetIcon, fxwIconBig, hIcon)
+ count++
+ return 1
+ })
+ fxwEnumWindows.Call(cb, 0)
+ log.Printf("lorca/firefox: applyWindowIcon: set icon on %d Firefox window(s)", count)
+}
+
+// registerAUMIDDisplayName writes AUMID + display name to HKCU\Software\Classes\AppUserModelId.
+// Display name is the portion of the AUMID before the first dot.
+func registerAUMIDDisplayName(aumid string) {
+ displayName := aumid
+ if dot := strings.IndexByte(aumid, '.'); dot >= 0 {
+ displayName = aumid[:dot]
+ }
+
+ const hkcu uintptr = 0x80000001 // HKEY_CURRENT_USER
+ const keyWrite = 0x20006 // KEY_WRITE
+ const regSZ = 1 // REG_SZ
+
+ keyPath, err := syscall.UTF16PtrFromString(`Software\Classes\AppUserModelId\` + aumid)
+ if err != nil {
+ return
+ }
+ var hKey uintptr
+ ret, _, _ := fxwRegCreateKeyExW.Call(
+ hkcu, uintptr(unsafe.Pointer(keyPath)),
+ 0, 0, 0, keyWrite, 0,
+ uintptr(unsafe.Pointer(&hKey)), 0,
+ )
+ if ret != 0 {
+ log.Printf("lorca/firefox: registerAUMIDDisplayName: RegCreateKeyEx %08x", ret)
+ return
+ }
+ defer fxwRegCloseKey.Call(hKey)
+
+ valName, err := syscall.UTF16PtrFromString("DisplayName")
+ if err != nil {
+ return
+ }
+ valData := syscall.StringToUTF16(displayName)
+ fxwRegSetValueExW.Call(
+ hKey,
+ uintptr(unsafe.Pointer(valName)),
+ 0, regSZ,
+ uintptr(unsafe.Pointer(&valData[0])),
+ uintptr(len(valData)*2),
+ )
+ runtime.KeepAlive(valData)
+ runtime.KeepAlive(valName)
+ runtime.KeepAlive(keyPath)
+}
+
+// applyFirefoxWindowAUMID sets the Windows App User Model ID on all visible top-level
+// Firefox windows, causing the taskbar to group them separately from other Firefox instances.
+func applyFirefoxWindowAUMID(launcherPID int, aumid string) {
+ registerAUMIDDisplayName(aumid)
+
+ // IID_IPropertyStore {886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99}
+ iid := [16]byte{
+ 0xEB, 0x8E, 0x6D, 0x88,
+ 0xF2, 0x8C,
+ 0x46, 0x44,
+ 0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99,
+ }
+ // All PKEY_AppUserModel_* properties share the same FMTID:
+ // {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}
+ // PKEY_AppUserModel_ID, pid=5
+ pkey := [20]byte{
+ 0x55, 0x28, 0x4C, 0x9F, // fmtid.Data1 (little-endian)
+ 0x79, 0x9F, // fmtid.Data2 (little-endian)
+ 0x39, 0x4B, // fmtid.Data3 (little-endian)
+ 0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3, // fmtid.Data4
+ 0x05, 0x00, 0x00, 0x00, // pid = 5
+ }
+ // PKEY_AppUserModel_PreventPinning, pid=9.
+ // Setting this to VT_BOOL(TRUE) suppresses both the "Pin to taskbar" option
+ // and the app relaunch action (e.g. "Firefox") from the taskbar context menu.
+ pkeyPreventPin := [20]byte{
+ 0x55, 0x28, 0x4C, 0x9F,
+ 0x79, 0x9F,
+ 0x39, 0x4B,
+ 0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3,
+ 0x09, 0x00, 0x00, 0x00, // pid = 9
+ }
+ aumidUTF16 := syscall.StringToUTF16(aumid)
+
+ pids := fxwDescendantPIDs(launcherPID)
+ if len(pids) == 0 {
+ return
+ }
+
+ count := 0
+ cb := syscall.NewCallback(func(hwnd, _ uintptr) uintptr {
+ var pid uint32
+ fxwGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
+ if !pids[pid] {
+ return 1
+ }
+ visible, _, _ := fxwIsWindowVisible.Call(hwnd)
+ if visible == 0 {
+ return 1
+ }
+
+ // *fxwIPropertyStore lets us access the vtable without a bare uintptr cast (go vet).
+ var pStore *fxwIPropertyStore
+ hr, _, _ := fxwSHGetPropertyStoreForWindow.Call(
+ hwnd,
+ uintptr(unsafe.Pointer(&iid[0])),
+ uintptr(unsafe.Pointer(&pStore)),
+ )
+ if hr != 0 || pStore == nil {
+ return 1
+ }
+
+ // VT_LPWSTR PROPVARIANT: vt=0x001F at offset 0, pwszVal pointer at offset 8.
+ var pv [24]byte
+ pv[0] = 0x1F
+ *(*uintptr)(unsafe.Pointer(&pv[8])) = uintptr(unsafe.Pointer(&aumidUTF16[0]))
+
+ // VT_BOOL PROPVARIANT: vt=0x000B at offset 0, VARIANT_TRUE (0xFFFF) at offset 8.
+ var pvBool [24]byte
+ pvBool[0] = 0x0B
+ pvBool[8] = 0xFF
+ pvBool[9] = 0xFF
+
+ // IPropertyStore vtable: [6]=SetValue [7]=Commit [2]=Release
+ syscall.SyscallN(pStore.vtbl[6], // SetValue: PKEY_AppUserModel_ID
+ uintptr(unsafe.Pointer(pStore)),
+ uintptr(unsafe.Pointer(&pkey[0])),
+ uintptr(unsafe.Pointer(&pv[0])),
+ )
+ syscall.SyscallN(pStore.vtbl[6], // SetValue: PKEY_AppUserModel_PreventPinning
+ uintptr(unsafe.Pointer(pStore)),
+ uintptr(unsafe.Pointer(&pkeyPreventPin[0])),
+ uintptr(unsafe.Pointer(&pvBool[0])),
+ )
+ syscall.SyscallN(pStore.vtbl[7], uintptr(unsafe.Pointer(pStore))) // Commit
+ syscall.SyscallN(pStore.vtbl[2], uintptr(unsafe.Pointer(pStore))) // Release
+
+ // Keep aumidUTF16 alive while pv holds a raw pointer into its array.
+ runtime.KeepAlive(aumidUTF16)
+ count++
+ return 1
+ })
+
+ // Retry every 500ms for up to 5 seconds; Firefox windows may not be visible immediately.
+ const maxAttempts = 10
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ time.Sleep(500 * time.Millisecond)
+ count = 0
+ fxwEnumWindows.Call(cb, 0)
+ if count > 0 {
+ log.Printf("lorca/firefox: applyWindowAUMID: set AUMID on %d Firefox window(s) (attempt %d)", count, attempt)
+ runtime.KeepAlive(aumidUTF16)
+ return
+ }
+ }
+ log.Printf("lorca/firefox: applyWindowAUMID: no Firefox windows found after %d attempts", maxAttempts)
+ runtime.KeepAlive(aumidUTF16)
+}
+
diff --git a/go.mod b/go.mod
index 56f9a04..c8a4e00 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,5 @@
-module github.com/zserge/lorca
+module github.com/davidarthurcole/lorca
-go 1.16
+go 1.25.0
-require golang.org/x/net v0.0.0-20200222125558-5a598a2470a0
+require golang.org/x/net v0.49.0
diff --git a/go.sum b/go.sum
index c957d22..beccae5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,2 @@
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0 h1:MsuvTghUPjX762sGLnGsxC3HM0B5r83wEtYcYR8/vRs=
-golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
diff --git a/locate.go b/locate.go
index 1b1659e..67b2747 100644
--- a/locate.go
+++ b/locate.go
@@ -13,7 +13,13 @@ var ChromeExecutable = LocateChrome
// LocateChrome returns a path to the Chrome binary, or an empty string if
// Chrome installation is not found.
-func LocateChrome() string {
+func LocateChrome(preferPath string) string {
+ // If preferPath is specified and it exists
+ if preferPath != "" {
+ if _, err := os.Stat(preferPath); err == nil {
+ return preferPath
+ }
+ }
// If env variable "LORCACHROME" specified and it exists
if path, ok := os.LookupEnv("LORCACHROME"); ok {
@@ -22,45 +28,168 @@ func LocateChrome() string {
}
}
- var paths []string
+ for _, path := range platformBrowserPaths() {
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+ return ""
+}
+
+// FindAllBrowsers returns paths to all browser executables found on the system.
+// preferPath is checked first; LORCACHROME env var second; then all platform paths.
+// The first entry, if any, is the recommended default (same selection as LocateChrome).
+func FindAllBrowsers(preferPath string) []string {
+ var result []string
+ seen := map[string]bool{}
+
+ add := func(p string) {
+ if p != "" && !seen[p] {
+ seen[p] = true
+ result = append(result, p)
+ }
+ }
+
+ if preferPath != "" {
+ if _, err := os.Stat(preferPath); err == nil {
+ add(preferPath)
+ }
+ }
+ if path, ok := os.LookupEnv("LORCACHROME"); ok {
+ if _, err := os.Stat(path); err == nil {
+ add(path)
+ }
+ }
+ for _, path := range platformBrowserPaths() {
+ if _, err := os.Stat(path); err == nil {
+ add(path)
+ }
+ }
+ return result
+}
+
+// platformBrowserPaths returns the ordered list of well-known browser paths for
+// the current OS.
+func platformBrowserPaths() []string {
switch runtime.GOOS {
case "darwin":
- paths = []string{
+ return []string{
+ //Chrome
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
+ //Brave
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
+ //Opera
+ "/Applications/Opera.app/Contents/MacOS/Opera",
+ //Vivaldi
+ "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi",
+ //Edge (why)
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+ // Firefox
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
+ "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox",
}
case "windows":
- paths = []string{
+ return []string{
+ //Chrome
os.Getenv("LocalAppData") + "/Google/Chrome/Application/chrome.exe",
os.Getenv("ProgramFiles") + "/Google/Chrome/Application/chrome.exe",
os.Getenv("ProgramFiles(x86)") + "/Google/Chrome/Application/chrome.exe",
os.Getenv("LocalAppData") + "/Chromium/Application/chrome.exe",
os.Getenv("ProgramFiles") + "/Chromium/Application/chrome.exe",
os.Getenv("ProgramFiles(x86)") + "/Chromium/Application/chrome.exe",
+ //Opera
+ os.Getenv("LocalAppData") + "/Programs/Opera/launcher.exe",
+ os.Getenv("LocalAppData") + "/Programs/Opera/opera.exe",
+ os.Getenv("ProgramFiles") + "/Opera/launcher.exe",
+ os.Getenv("ProgramFiles") + "/Opera/opera.exe",
+ //Brave
+ os.Getenv("LocalAppData") + "/BraveSoftware/Brave-Browser/Application/brave.exe",
+ os.Getenv("ProgramFiles") + "/BraveSoftware/Brave-Browser/Application/brave.exe",
+ //Vivaldi
+ os.Getenv("LocalAppData") + "/Vivaldi/Application/vivaldi.exe",
+ os.Getenv("ProgramFiles") + "/Vivaldi/Application/vivaldi.exe",
+ //Edge
os.Getenv("ProgramFiles(x86)") + "/Microsoft/Edge/Application/msedge.exe",
os.Getenv("ProgramFiles") + "/Microsoft/Edge/Application/msedge.exe",
+ // Firefox
+ os.Getenv("LocalAppData") + "/Mozilla Firefox/firefox.exe",
+ os.Getenv("ProgramFiles") + "/Mozilla Firefox/firefox.exe",
+ os.Getenv("ProgramFiles(x86)") + "/Mozilla Firefox/firefox.exe",
}
default:
- paths = []string{
+ return []string{
+ // Chrome / Chromium
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
+ // Opera
+ "/usr/bin/opera",
+ "/snap/bin/opera",
+ // Brave
+ "/usr/bin/brave-browser",
+ "/usr/bin/brave-browser-stable",
+ "/snap/bin/brave",
+ // Vivaldi
+ "/usr/bin/vivaldi",
+ "/usr/bin/vivaldi-stable",
+ // Edge
+ "/usr/bin/microsoft-edge",
+ "/usr/bin/microsoft-edge-stable",
+ "/snap/bin/microsoft-edge",
+ // Firefox
+ "/usr/bin/firefox",
+ "/usr/bin/firefox-esr",
+ "/snap/bin/firefox",
}
}
+}
- for _, path := range paths {
- if _, err := os.Stat(path); os.IsNotExist(err) {
- continue
+// LocateFirefox returns a path to a Firefox binary, or an empty string if
+// none is found. It checks preferPath, then the LORCAFIREFOX env var, then
+// well-known platform paths.
+func LocateFirefox(preferPath string) string {
+ if preferPath != "" {
+ if _, err := os.Stat(preferPath); err == nil {
+ return preferPath
+ }
+ }
+ if path, ok := os.LookupEnv("LORCAFIREFOX"); ok {
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+ var paths []string
+ switch runtime.GOOS {
+ case "darwin":
+ paths = []string{
+ "/Applications/Firefox.app/Contents/MacOS/firefox",
+ "/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox",
+ }
+ case "windows":
+ paths = []string{
+ os.Getenv("LocalAppData") + "/Mozilla Firefox/firefox.exe",
+ os.Getenv("ProgramFiles") + "/Mozilla Firefox/firefox.exe",
+ os.Getenv("ProgramFiles(x86)") + "/Mozilla Firefox/firefox.exe",
+ }
+ default:
+ paths = []string{
+ "/usr/bin/firefox",
+ "/usr/bin/firefox-esr",
+ "/snap/bin/firefox",
+ }
+ }
+ for _, p := range paths {
+ if _, err := os.Stat(p); err == nil {
+ return p
}
- return path
}
return ""
}
diff --git a/locate_test.go b/locate_test.go
index 9498f57..139b5f4 100644
--- a/locate_test.go
+++ b/locate_test.go
@@ -1,17 +1,46 @@
package lorca
import (
+ "context"
+ "os"
"os/exec"
"testing"
+ "time"
)
func TestLocate(t *testing.T) {
- if exe := ChromeExecutable(); exe == "" {
+ if exe := ChromeExecutable(""); exe == "" {
t.Fatal()
} else {
t.Log(exe)
- b, err := exec.Command(exe, "--version").CombinedOutput()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ b, err := exec.CommandContext(ctx, exe, "--version").CombinedOutput()
t.Log(string(b))
t.Log(err)
}
}
+
+func TestLocateFirefoxEnvVar(t *testing.T) {
+ path := t.TempDir() + "/firefox"
+ // Create a dummy file so os.Stat succeeds
+ if err := os.WriteFile(path, []byte(""), 0755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("LORCAFIREFOX", path)
+ got := LocateFirefox("")
+ if got != path {
+ t.Fatalf("expected %q, got %q", path, got)
+ }
+}
+
+func TestLocateFirefoxPreferPath(t *testing.T) {
+ path := t.TempDir() + "/my-firefox"
+ if err := os.WriteFile(path, []byte(""), 0755); err != nil {
+ t.Fatal(err)
+ }
+ got := LocateFirefox(path)
+ if got != path {
+ t.Fatalf("expected %q, got %q", path, got)
+ }
+}
diff --git a/messagebox.go b/messagebox.go
index f14ea2c..98bcc21 100644
--- a/messagebox.go
+++ b/messagebox.go
@@ -1,4 +1,5 @@
-//+build !windows
+//go:build !windows
+// +build !windows
package lorca
diff --git a/messagebox_windows.go b/messagebox_windows.go
index acb4f4b..3ed1c85 100644
--- a/messagebox_windows.go
+++ b/messagebox_windows.go
@@ -1,4 +1,5 @@
-//+build windows
+//go:build windows
+// +build windows
package lorca
@@ -13,7 +14,9 @@ func messageBox(title, text string) bool {
mbYesNo := 0x00000004
mbIconQuestion := 0x00000020
idYes := 6
- ret, _, _ := messageBoxW.Call(0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))),
- uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(title))), uintptr(uint(mbYesNo|mbIconQuestion)))
+ titlePtr, _ := syscall.UTF16PtrFromString(title)
+ textPtr, _ := syscall.UTF16PtrFromString(text)
+ ret, _, _ := messageBoxW.Call(0, uintptr(unsafe.Pointer(textPtr)),
+ uintptr(unsafe.Pointer(titlePtr)), uintptr(uint(mbYesNo|mbIconQuestion)))
return int(ret) == idYes
}
diff --git a/relay.go b/relay.go
new file mode 100644
index 0000000..945f8e2
--- /dev/null
+++ b/relay.go
@@ -0,0 +1,226 @@
+package lorca
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+
+ "golang.org/x/net/websocket"
+)
+
+// bindingScript returns the JS that installs window[name] as a relay-backed function.
+// Send logic is inlined (not delegated to window.__lorcaSend) so the function holds no
+// sandbox-realm references; Firefox Xray allows method calls on sandbox objects but
+// throws "Permission denied to access property 'length'" when page code sees sandbox functions.
+func bindingScript(name string) string {
+ body := `var args = Array.prototype.slice.call(arguments); ` +
+ `var seq = (window['` + name + `']._seq = (window['` + name + `']._seq || 0) + 1); ` +
+ `return new Promise(function(resolve, reject) { ` +
+ `window.__lorcaPending.set('` + name + `:' + seq, {resolve: resolve, reject: reject}); ` +
+ `var _m = JSON.stringify({name: '` + name + `', seq: seq, args: args}); ` +
+ `if (window.__lorcaOpen) { window.__lorcaWS.send(_m); } else { window.__lorcaQueue.push(_m); } ` +
+ `});`
+ // IIFE preserves _seq across re-evals so in-flight calls are not orphaned.
+ return `(function() { var _s = (window['` + name + `'] && window['` + name + `']._seq) || 0; ` +
+ `window['` + name + `'] = function() { ` + body + ` }; ` +
+ `window['` + name + `']._seq = _s; })()`
+}
+
+// bootstrapTemplate sets up the relay WebSocket and lorca messaging primitives.
+// window.__lorcaSend is intentionally absent: Firefox Xray allows method calls on
+// sandbox objects but throws on function .length access. __lorcaSetupWS is kept
+// in IIFE scope (not on window) for the same reason.
+const bootstrapTemplate = `(function() {
+ var _proto = window.location && window.location.protocol
+ if (_proto && _proto !== 'http:' && _proto !== 'https:' && _proto !== 'data:') { return }
+ if (_proto !== 'data:') { var _orig = window.location.origin; if (!_orig || _orig === 'null') { return } }
+ if (window.__lorcaWS && window.__lorcaWS.readyState <= 1) { return }
+ window.__lorcaPending = new Map()
+ window.__lorcaQueue = []
+ window.__lorcaOpen = false
+ function __lorcaSetupWS() {
+ var ws = new WebSocket('ws://127.0.0.1:__RELAY_PORT__')
+ window.__lorcaWS = ws
+ ws.onopen = function() { window.__lorcaOpen = true; for (var i = 0; i < window.__lorcaQueue.length; i++) { ws.send(window.__lorcaQueue[i]) } window.__lorcaQueue = [] }
+ ws.onmessage = function(e) { var msg = JSON.parse(e.data); if (msg.type === 'result') { var cb = window.__lorcaPending.get(msg.name + ':' + msg.seq); if (cb) { if (msg.error) { cb.reject(new Error(msg.error)); } else { cb.resolve(msg.result); } window.__lorcaPending.delete(msg.name + ':' + msg.seq); } } }
+ ws.onclose = function() { window.__lorcaOpen = false; window.__lorcaPending.forEach(function(cb) { cb.reject(new Error('relay reconnecting')) }); window.__lorcaPending = new Map(); window.__lorcaQueue = []; setTimeout(function() { if (!window.__lorcaWS || window.__lorcaWS.readyState > 1) { __lorcaSetupWS() } }, 500) }
+ }
+ __lorcaSetupWS()
+})()`
+
+type relay struct {
+ mu sync.Mutex // guards bindings, names, client (held briefly)
+ writeMu sync.Mutex // serialises WebSocket writes (can be held longer)
+ bindings map[string]bindingFunc
+ names []string
+ client *websocket.Conn
+ port int
+ ln net.Listener
+ server *http.Server
+}
+
+func newRelay() (*relay, error) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ r := &relay{
+ port: ln.Addr().(*net.TCPAddr).Port,
+ ln: ln,
+ bindings: map[string]bindingFunc{},
+ }
+ // Use a custom Server with no origin check so that pages loaded from
+ // data: URIs (null origin) and file: URIs can connect to the relay.
+ wsServer := websocket.Server{
+ Handler: websocket.Handler(r.handleClient),
+ Handshake: func(cfg *websocket.Config, req *http.Request) error {
+ return nil // accept any origin
+ },
+ }
+ mux := http.NewServeMux()
+ mux.Handle("/", wsServer)
+ r.server = &http.Server{Handler: mux}
+ go r.server.Serve(ln)
+ return r, nil
+}
+
+func (r *relay) bootstrapScript() string {
+ return strings.ReplaceAll(bootstrapTemplate, "__RELAY_PORT__", fmt.Sprintf("%d", r.port))
+}
+
+// bind registers name -> f. Re-registering an existing name only updates the handler
+// (no register message; a second register would reset the JS-side seq counter).
+func (r *relay) bind(name string, f bindingFunc) error {
+ r.mu.Lock()
+ _, exists := r.bindings[name]
+ r.bindings[name] = f
+ if exists {
+ r.mu.Unlock()
+ return nil
+ }
+ r.names = append(r.names, name)
+ client := r.client
+ r.mu.Unlock()
+
+ if client == nil {
+ return nil
+ }
+ r.writeMu.Lock()
+ err := websocket.JSON.Send(client, map[string]string{"type": "register", "name": name})
+ r.writeMu.Unlock()
+ return err
+}
+
+func (r *relay) handleClient(ws *websocket.Conn) {
+ r.mu.Lock()
+ old := r.client
+ r.client = ws
+ names := make([]string, len(r.names))
+ copy(names, r.names)
+ r.mu.Unlock()
+
+ log.Printf("lorca/relay: page connected, replaying %d binding(s)", len(names))
+
+ r.writeMu.Lock()
+ for _, name := range names {
+ if err := websocket.JSON.Send(ws, map[string]string{"type": "register", "name": name}); err != nil {
+ log.Printf("lorca/relay: send register(%s) failed: %v", name, err)
+ r.writeMu.Unlock()
+ if old != nil {
+ old.Close()
+ }
+ return
+ }
+ }
+ r.writeMu.Unlock()
+
+ if old != nil {
+ old.Close()
+ }
+
+ type callMsg struct {
+ Name string `json:"name"`
+ Seq int `json:"seq"`
+ Args []json.RawMessage `json:"args"`
+ }
+ type resultMsg struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Seq int `json:"seq"`
+ Result *json.RawMessage `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+ }
+
+ for {
+ var call callMsg
+ if err := websocket.JSON.Receive(ws, &call); err != nil {
+ log.Printf("lorca/relay: client read error (disconnect?): %v", err)
+ break
+ }
+ log.Printf("lorca/relay: call %s seq=%d", call.Name, call.Seq)
+ r.mu.Lock()
+ f, ok := r.bindings[call.Name]
+ r.mu.Unlock()
+ if !ok {
+ log.Printf("lorca/relay: no binding for %q", call.Name)
+ continue
+ }
+ name, seq, args := call.Name, call.Seq, call.Args
+ go func() {
+ msg := resultMsg{Type: "result", Name: name, Seq: seq}
+ res, err := f(args)
+ if err != nil {
+ msg.Error = err.Error()
+ } else if b, err2 := json.Marshal(res); err2 != nil {
+ msg.Error = err2.Error()
+ } else {
+ raw := json.RawMessage(b)
+ msg.Result = &raw
+ }
+ msgBytes, marshalErr := json.Marshal(msg)
+ if marshalErr != nil {
+ log.Printf("lorca/relay: marshal envelope error for %s seq=%d: %v", name, seq, marshalErr)
+ return
+ }
+ r.mu.Lock()
+ client := r.client
+ r.mu.Unlock()
+ if client != ws {
+ return // result for a navigated-away page
+ }
+ r.writeMu.Lock()
+ defer r.writeMu.Unlock()
+ // Re-verify client under mu in case it changed while waiting for writeMu.
+ r.mu.Lock()
+ active := r.client == ws
+ r.mu.Unlock()
+ if active {
+ if err := websocket.Message.Send(client, string(msgBytes)); err != nil {
+ log.Printf("lorca/relay: send error for %s seq=%d: %v", name, seq, err)
+ client.Close()
+ }
+ }
+ }()
+ }
+
+ r.mu.Lock()
+ if r.client == ws {
+ r.client = nil
+ }
+ r.mu.Unlock()
+}
+
+func (r *relay) close() {
+ r.server.Close()
+ r.mu.Lock()
+ client := r.client
+ r.client = nil
+ r.mu.Unlock()
+ if client != nil {
+ client.Close()
+ }
+}
diff --git a/relay_test.go b/relay_test.go
new file mode 100644
index 0000000..274e693
--- /dev/null
+++ b/relay_test.go
@@ -0,0 +1,271 @@
+package lorca
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "golang.org/x/net/websocket"
+)
+
+func dialRelay(t *testing.T, port int) *websocket.Conn {
+ t.Helper()
+ ws, err := websocket.Dial(fmt.Sprintf("ws://127.0.0.1:%d/", port), "", "http://127.0.0.1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ws.SetDeadline(time.Now().Add(5 * time.Second))
+ return ws
+}
+
+type regMsg struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+}
+
+type resMsg struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Seq int `json:"seq"`
+ Result json.RawMessage `json:"result"`
+ Error string `json:"error"`
+}
+
+// TestRelayReplay verifies that bindings registered BEFORE a client connects
+// are replayed as register messages when the client connects.
+func TestRelayReplay(t *testing.T) {
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ if err := r.bind("greet", func(args []json.RawMessage) (interface{}, error) {
+ return "hello", nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ ws := dialRelay(t, r.port)
+ defer ws.Close()
+
+ var msg regMsg
+ if err := websocket.JSON.Receive(ws, &msg); err != nil {
+ t.Fatal(err)
+ }
+ if msg.Type != "register" || msg.Name != "greet" {
+ t.Fatalf("expected {register greet}, got %+v", msg)
+ }
+}
+
+// TestRelayRegisterAfterConnect verifies that binding registered AFTER a client
+// connects sends a register message to the already-connected client.
+func TestRelayRegisterAfterConnect(t *testing.T) {
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ ws := dialRelay(t, r.port)
+ defer ws.Close()
+
+ if err := r.bind("add", func(args []json.RawMessage) (interface{}, error) {
+ var a, b int
+ if err := json.Unmarshal(args[0], &a); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(args[1], &b); err != nil {
+ return nil, err
+ }
+ return a + b, nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ var msg regMsg
+ if err := websocket.JSON.Receive(ws, &msg); err != nil {
+ t.Fatal(err)
+ }
+ if msg.Type != "register" || msg.Name != "add" {
+ t.Fatalf("expected {register add}, got %+v", msg)
+ }
+}
+
+// TestRelayCallDispatch verifies that sending a call message invokes the
+// binding and returns a result message.
+func TestRelayCallDispatch(t *testing.T) {
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ if err := r.bind("add", func(args []json.RawMessage) (interface{}, error) {
+ var a, b int
+ json.Unmarshal(args[0], &a)
+ json.Unmarshal(args[1], &b)
+ return a + b, nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ ws := dialRelay(t, r.port)
+ defer ws.Close()
+
+ // Drain the register message
+ var reg regMsg
+ if err := websocket.JSON.Receive(ws, ®); err != nil {
+ t.Fatal(err)
+ }
+
+ // Send a call
+ if err := websocket.JSON.Send(ws, map[string]interface{}{
+ "name": "add",
+ "seq": 1,
+ "args": []interface{}{2, 3},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ var res resMsg
+ if err := websocket.JSON.Receive(ws, &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Type != "result" || res.Seq != 1 || string(res.Result) != "5" || res.Error != "" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+}
+
+// TestRelayCallError verifies that a binding that returns an error sends back
+// an error field in the result message.
+func TestRelayCallError(t *testing.T) {
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ if err := r.bind("fail", func(args []json.RawMessage) (interface{}, error) {
+ return nil, errors.New("something went wrong")
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ ws := dialRelay(t, r.port)
+ defer ws.Close()
+
+ var reg regMsg
+ if err := websocket.JSON.Receive(ws, ®); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := websocket.JSON.Send(ws, map[string]interface{}{
+ "name": "fail",
+ "seq": 7,
+ "args": []interface{}{},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ var res resMsg
+ if err := websocket.JSON.Receive(ws, &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Type != "result" || res.Seq != 7 || res.Error != "something went wrong" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+}
+
+// TestRelayRebind verifies that calling bind() with an existing name updates
+// the handler but does NOT send a second register message.
+func TestRelayRebind(t *testing.T) {
+ r, err := newRelay()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.close()
+
+ var called atomic.Int32
+ mkHandler := func(v int32) bindingFunc {
+ return func(args []json.RawMessage) (interface{}, error) {
+ called.Store(v)
+ return v, nil
+ }
+ }
+
+ r.bind("fn", mkHandler(1))
+
+ ws := dialRelay(t, r.port)
+ defer ws.Close()
+
+ // Drain the first register
+ var reg regMsg
+ if err := websocket.JSON.Receive(ws, ®); err != nil {
+ t.Fatal(err)
+ }
+
+ // Rebind -must NOT send another register message to the client
+ r.bind("fn", mkHandler(2))
+
+ // Send a call; result should come from handler v=2
+ if err := websocket.JSON.Send(ws, map[string]interface{}{
+ "name": "fn",
+ "seq": 1,
+ "args": []interface{}{},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ var res resMsg
+ if err := websocket.JSON.Receive(ws, &res); err != nil {
+ t.Fatal(err)
+ }
+ // Explicitly confirm the relay sent a result (not a second register message).
+ if res.Type != "result" {
+ t.Fatalf("expected result message, got type=%q (possible spurious register)", res.Type)
+ }
+ if string(res.Result) != "2" {
+ t.Fatalf("expected handler v=2 result, got %s", res.Result)
+ }
+ if v := called.Load(); v != 2 {
+ t.Fatalf("expected called=2, got %d", v)
+ }
+}
+
+// TestBootstrapNoSandboxRealmFunctions guards against regressing to
+// new window.Function. Under Firefox's BiDi preload sandbox, new window.Function
+// creates a function whose realm is determined by the call site (the sandbox),
+// not by the constructor's origin. Page code then throws "Permission denied to
+// access property 'length'" when it inspects those sandbox-realm functions via
+// Firefox's Xray wrapper -breaking WebSocket event dispatch and binding calls.
+// window.eval("...") is the correct fix: it evaluates code in the page realm.
+func TestBootstrapNoSandboxRealmFunctions(t *testing.T) {
+ if strings.Contains(bootstrapTemplate, "new window.Function") {
+ t.Error("bootstrapTemplate must not use new window.Function: functions created " +
+ "this way run in the preload sandbox realm, not page realm. " +
+ "Use window.eval(\"...\") instead.")
+ }
+ if strings.Contains(bootstrapTemplate, "new Function(") {
+ t.Error("bootstrapTemplate must not use bare new Function(...): same sandbox-realm issue. " +
+ "Use window.eval(\"...\") instead.")
+ }
+}
+
+// TestBootstrapNoWindowEval guards against re-introducing window.eval into the
+// bootstrap template. window.eval() was tried (Approach 4) to escape the
+// Firefox BiDi preload sandbox realm, but confirmed not to work: functions
+// created via window.eval from a sandbox still run in the sandbox realm and
+// cause "Permission denied to access property 'length'" when page-realm code
+// introspects them. The bootstrap must NOT use window.eval.
+func TestBootstrapNoWindowEval(t *testing.T) {
+ if strings.Contains(bootstrapTemplate, `window.eval(`) {
+ t.Error("bootstrapTemplate must not use window.eval(): it does not escape " +
+ "the Firefox preload sandbox realm (Approach 4, confirmed non-fix). " +
+ "Use bindingScript injection via injectBinding instead.")
+ }
+}
diff --git a/ui.go b/ui.go
index 6849d9d..6c7ae72 100644
--- a/ui.go
+++ b/ui.go
@@ -1,12 +1,14 @@
package lorca
import (
+ "encoding/binary"
"encoding/json"
"errors"
"fmt"
- "io/ioutil"
"os"
+ "path/filepath"
"reflect"
+ "strings"
)
// UI interface allows talking to the HTML5 UI from Go.
@@ -18,12 +20,22 @@ type UI interface {
Eval(js string) Value
Done() <-chan struct{}
Close() error
+ GetDebugPort() int
+ // SetBlockBackNavigation controls whether navigations away from the loaded
+ // app URL (e.g. the user pressing Back) are intercepted and redirected back.
+ // Call with true after Load() to prevent the browser from showing a blank
+ // page when the user presses Back or uses a keyboard shortcut.
+ SetBlockBackNavigation(enable bool)
+ // SetAppUserModelID sets a Windows App User Model ID on the browser window
+ // so it is grouped separately from other browser instances in the taskbar.
+ // No-op on non-Windows platforms and for the Chrome backend.
+ SetAppUserModelID(id string)
}
type ui struct {
- chrome *chrome
- done chan struct{}
- tmpDir string
+ browser browserImpl
+ relay *relay
+ tmpDir string
}
var defaultChromeArgs = []string{
@@ -31,12 +43,13 @@ var defaultChromeArgs = []string{
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
+ "--disable-crash-reporter",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-dev-shm-usage",
"--disable-infobars",
"--disable-extensions",
- "--disable-features=site-per-process",
+ "--disable-features=site-per-process,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessChecks",
"--disable-hang-monitor",
"--disable-ipc-flooding-protection",
"--disable-popup-blocking",
@@ -49,57 +62,129 @@ var defaultChromeArgs = []string{
"--no-first-run",
"--no-default-browser-check",
"--safebrowsing-disable-auto-update",
- "--enable-automation",
+ //"--enable-automation", https://github.com/zserge/lorca/issues/167
"--password-store=basic",
"--use-mock-keychain",
"--remote-allow-origins=*",
}
-// New returns a new HTML5 UI for the given URL, user profile directory, window
-// size and other options passed to the browser engine. If URL is an empty
-// string - a blank page is displayed. If user profile directory is an empty
-// string - a temporary directory is created and it will be removed on
-// ui.Close(). You might want to use "--headless" custom CLI argument to test
-// your UI code.
-func New(url, dir string, width, height int, customArgs ...string) (UI, error) {
+// BrowserHint tells NewWithBrowser which browser backend to use.
+type BrowserHint string
+
+const (
+ // BrowserAuto selects the backend by inspecting the resolved binary path.
+ BrowserAuto BrowserHint = "auto"
+ // BrowserChrome selects the Chromium-family CDP backend.
+ BrowserChrome BrowserHint = "chrome"
+ // BrowserFirefox selects the Firefox WebDriver BiDi backend.
+ BrowserFirefox BrowserHint = "firefox"
+)
+
+// NewWithBrowser is like New but lets the caller specify which browser backend
+// to use. hint == BrowserAuto inspects the resolved binary name; a path
+// containing "firefox" (case-insensitive) selects the Firefox backend.
+// appName is an optional human-readable name shown in the Firefox tab strip
+// (via CSS ::before); pass an empty string to omit the label.
+func NewWithBrowser(url, dir, preferPath string, width, height int, hint BrowserHint, appName, appIconPath string, customArgs ...string) (UI, error) {
if url == "" {
url = "data:text/html,"
}
tmpDir := ""
if dir == "" {
- name, err := ioutil.TempDir("", "lorca")
+ name, err := os.MkdirTemp("", "lorca")
if err != nil {
return nil, err
}
dir, tmpDir = name, name
}
- args := append(defaultChromeArgs, fmt.Sprintf("--app=%s", url))
- args = append(args, fmt.Sprintf("--user-data-dir=%s", dir))
- args = append(args, fmt.Sprintf("--window-size=%d,%d", width, height))
- args = append(args, customArgs...)
- args = append(args, "--remote-debugging-port=0")
- chrome, err := newChromeWithArgs(ChromeExecutable(), args...)
- done := make(chan struct{})
+ r, err := newRelay()
if err != nil {
return nil, err
}
- go func() {
- chrome.cmd.Wait()
- close(done)
- }()
- return &ui{chrome: chrome, done: done, tmpDir: tmpDir}, nil
+ // Resolve binary and select backend.
+ var binary string
+ useFirefox := false
+ switch hint {
+ case BrowserFirefox:
+ binary = LocateFirefox(preferPath)
+ if binary == "" {
+ r.close()
+ return nil, errors.New("lorca: no Firefox binary found")
+ }
+ useFirefox = true
+ default: // BrowserChrome or BrowserAuto
+ binary = ChromeExecutable(preferPath)
+ if hint == BrowserAuto {
+ useFirefox = strings.Contains(strings.ToLower(binary), "firefox")
+ }
+ }
+
+ var browser browserImpl
+ if useFirefox {
+ if err := setupFirefoxProfile(dir, appName, appIconPath); err != nil {
+ fmt.Fprintf(os.Stderr, "lorca: firefox profile setup: %v\n", err)
+ }
+ args := append(append([]string{}, defaultFirefoxArgs...),
+ "--profile", dir,
+ )
+ if width > 0 {
+ args = append(args, fmt.Sprintf("--width=%d", width))
+ }
+ if height > 0 {
+ args = append(args, fmt.Sprintf("--height=%d", height))
+ }
+ args = append(args, customArgs...)
+ args = append(args, url)
+ browser, err = newFirefoxWithArgs(binary, appIconPath, args...)
+ } else {
+ args := append(append([]string{}, defaultChromeArgs...),
+ fmt.Sprintf("--app=%s", url),
+ fmt.Sprintf("--user-data-dir=%s", dir),
+ fmt.Sprintf("--window-size=%d,%d", width, height),
+ )
+ args = append(args, customArgs...)
+ browser, err = newChromeWithArgs(binary, args...)
+ }
+ if err != nil {
+ r.close()
+ return nil, err
+ }
+
+ if err := browser.injectScript(r.bootstrapScript()); err != nil {
+ r.close()
+ browser.kill()
+ <-browser.done()
+ return nil, err
+ }
+
+ return &ui{browser: browser, relay: r, tmpDir: tmpDir}, nil
+}
+
+// New returns a new HTML5 UI for the given URL, user profile directory, window
+// size and other options passed to the browser engine. If URL is an empty
+// string - a blank page is displayed. If user profile directory is an empty
+// string - a temporary directory is created and it will be removed on
+// ui.Close(). appName is an optional human-readable application name shown in
+// the Firefox tab strip area; pass an empty string to omit it. appIconPath is
+// an optional path to a .ico file used to set the window icon when running
+// under Firefox; pass an empty string to fall back to PE resource 1
+// (goversioninfo convention) or to skip icon setup. You might want to use
+// "--headless" custom CLI argument to test your UI code.
+func New(url, dir, preferPath string, width, height int, appName, appIconPath string, customArgs ...string) (UI, error) {
+ return NewWithBrowser(url, dir, preferPath, width, height, BrowserAuto, appName, appIconPath, customArgs...)
}
func (u *ui) Done() <-chan struct{} {
- return u.done
+ return u.browser.done()
}
func (u *ui) Close() error {
- // ignore err, as the chrome process might be already dead, when user close the window.
- u.chrome.kill()
- <-u.done
+ u.relay.close()
+ // ignore err, as the browser process might be already dead, when user closes the window.
+ u.browser.kill()
+ <-u.browser.done()
if u.tmpDir != "" {
if err := os.RemoveAll(u.tmpDir); err != nil {
return err
@@ -108,7 +193,7 @@ func (u *ui) Close() error {
return nil
}
-func (u *ui) Load(url string) error { return u.chrome.load(url) }
+func (u *ui) Load(url string) error { return u.browser.load(url) }
func (u *ui) Bind(name string, f interface{}) error {
v := reflect.ValueOf(f)
@@ -121,7 +206,7 @@ func (u *ui) Bind(name string, f interface{}) error {
return errors.New("function may only return a value or a value+error")
}
- return u.chrome.bind(name, func(raw []json.RawMessage) (interface{}, error) {
+ if err := u.relay.bind(name, func(raw []json.RawMessage) (interface{}, error) {
if len(raw) != v.Type().NumIn() {
return nil, errors.New("function arguments mismatch")
}
@@ -160,18 +245,213 @@ func (u *ui) Bind(name string, f interface{}) error {
default:
return nil, errors.New("unexpected number of return values")
}
- })
+ }); err != nil {
+ return err
+ }
+ // Install the binding on the current page immediately AND register it for
+ // all future page loads via addScriptToEvaluateOnNewDocument. This ensures
+ // bound functions are available synchronously before any page JS runs,
+ // avoiding the race where a page mounts before the relay WebSocket has
+ // delivered its register messages.
+ return u.browser.injectBinding(name)
}
func (u *ui) Eval(js string) Value {
- v, err := u.chrome.eval(js)
+ v, err := u.browser.eval(js)
return value{err: err, raw: v}
}
func (u *ui) SetBounds(b Bounds) error {
- return u.chrome.setBounds(b)
+ return u.browser.setBounds(b)
}
func (u *ui) Bounds() (Bounds, error) {
- return u.chrome.bounds()
+ return u.browser.bounds()
+}
+
+func (u *ui) GetDebugPort() int {
+ switch b := u.browser.(type) {
+ case *chrome:
+ return b.debugPort
+ case *firefox:
+ return b.debugPort
+ default:
+ return 0
+ }
+}
+
+func (u *ui) SetBlockBackNavigation(enable bool) {
+ u.browser.setBlockBackNavigation(enable)
+}
+
+func (u *ui) SetAppUserModelID(id string) {
+ u.browser.setAppUserModelID(id)
+}
+
+// setupFirefoxProfile writes userChrome.css and user.js into the Firefox
+// profile directory so the browser launches with no navigation toolbar or tab
+// strip, matching the clean app-mode appearance that Chrome provides via --app.
+// appName, if non-empty, is displayed as a label in the tab strip area via a
+// CSS ::before pseudo-element on #TabsToolbar. iconPath, if non-empty, is the
+// path to a .ico file; a PNG image is extracted from it and shown to the left
+// of the label.
+func setupFirefoxProfile(dir, appName, iconPath string) error {
+ chromeDir := filepath.Join(dir, "chrome")
+ if err := os.MkdirAll(chromeDir, 0755); err != nil {
+ return err
+ }
+ // Hide nav/bookmarks/menu bars. #TabsToolbar is kept (not hidden) because on
+ // Windows it hosts the min/max/close buttons and window drag region.
+ css := "#nav-bar { display: none !important; }\n" +
+ "#PersonalToolbar { display: none !important; }\n" +
+ "#toolbar-menubar { display: none !important; }\n" +
+ // Hide tab scrollbox contents; keep #TabsToolbar for window controls.
+ "#tabbrowser-arrowscrollbox { display: none !important; }\n" +
+ ".tabbrowser-tab { display: none !important; }\n" +
+ "#new-tab-button { display: none !important; }\n" +
+ ".tabs-newtab-button { display: none !important; }\n" +
+ "toolbarbutton[command=\"cmd_newNavigatorTab\"] { display: none !important; }\n" +
+ "#alltabs-button { display: none !important; }\n" +
+ "#firefox-view-button { display: none !important; }\n" +
+ // toolbarseparator is toolbar-only (not menus), safe to hide globally.
+ "toolbarseparator { display: none !important; }\n" +
+ "toolbarspring { display: none !important; }\n" +
+ // Collapse #tabbrowser-tabs via max-width/overflow instead of display:none;
+ // display:none breaks Firefox's internal tab-switching state (gray content area).
+ // Inline borders/padding also need zeroing - max-width:0+overflow:hidden won't suppress them.
+ "#tabbrowser-tabs { flex: none !important; -moz-box-flex: 0 !important; " +
+ "max-width: 0 !important; min-width: 0 !important; " +
+ "max-height: 0 !important; overflow: hidden !important; " +
+ "border: none !important; border-inline-start: none !important; " +
+ "padding: 0 !important; padding-inline-start: 0 !important; " +
+ "margin: 0 !important; margin-inline-start: 0 !important; }\n" +
+ // XUL splitters reserve space even with display:none; width:0 is required too.
+ "#vertical-pinned-tabs-splitter { display: none !important; " +
+ "width: 0 !important; min-width: 0 !important; }\n" +
+ ".titlebar-placeholder { display: none !important; }\n" +
+ ".titlebar-spacer { display: none !important; }\n" +
+ // Sidebar: both pre-131 panel and 131+ revamp launcher. Splitters need width:0 too.
+ "#sidebar-main { display: none !important; width: 0 !important; min-width: 0 !important; }\n" +
+ "#sidebar-box { display: none !important; width: 0 !important; min-width: 0 !important; }\n" +
+ "#sidebar-splitter, .sidebar-splitter { display: none !important; width: 0 !important; min-width: 0 !important; }\n" +
+ "#browser > splitter { display: none !important; width: 0 !important; min-width: 0 !important; }\n" +
+ "#sidebar-button { display: none !important; }\n" +
+ // Disable new-tab/new-window shortcuts; command-attr selectors cover CustomizableUI re-insertions.
+ "#key_newNavigatorTab { display: none !important; }\n" +
+ "#key_newNavigatorTabNoEvent { display: none !important; }\n" +
+ "#key_newNavigatorWindow { display: none !important; }\n" +
+ "key[command=\"cmd_newNavigatorTab\"] { display: none !important; }\n" +
+ "key[command=\"cmd_newNavigatorTabNoEvent\"] { display: none !important; }\n" +
+ "key[command=\"cmd_newNavigatorWindow\"] { display: none !important; }\n" +
+ // Suppress toolbar/tab-strip context menus.
+ "#toolbar-context-menu { display: none !important; }\n" +
+ "#tabContextMenu { display: none !important; }\n" +
+ // Page context menu: hide bookmark-star and AI chatbot (plus adjacent separators).
+ "#context-bookmarkpage { display: none !important; }\n" +
+ "#context-ask-chat { display: none !important; }\n" +
+ "menuseparator:has(+ #context-ask-chat) { display: none !important; }\n" +
+ "#context-ask-chat + menuseparator { display: none !important; }\n"
+
+ // #TabsToolbar: CSS flex so ::before flex:1 is honoured (XUL box ignores it on generated content).
+ // min-height:0 prevents --tabstrip-min-height (44px) from leaving a gap above caption buttons.
+ css += "#TabsToolbar { display: flex !important; align-items: center !important; min-height: 0 !important; }\n" +
+ ".toolbar-items { display: none !important; }\n"
+
+ if appName != "" {
+ // Escape for CSS string literal.
+ escapedName := strings.ReplaceAll(appName, `\`, `\\`)
+ escapedName = strings.ReplaceAll(escapedName, `"`, `\"`)
+
+ // Extract smallest PNG from .ico; background-image allows explicit sizing unlike content:url().
+ iconCSS := ""
+ paddingStart := "8px"
+ if iconPath != "" {
+ if png := extractSmallPNGFromICO(iconPath); png != nil {
+ iconFile := filepath.Join(chromeDir, "app-icon.png")
+ if os.WriteFile(iconFile, png, 0644) == nil {
+ // 8px gap + 16px icon + 6px gap = 30px total padding-start.
+ iconCSS = "background-image: url(\"app-icon.png\"); " +
+ "background-size: 16px 16px; " +
+ "background-repeat: no-repeat; " +
+ "background-position: 8px center; "
+ paddingStart = "30px"
+ }
+ }
+ }
+
+ css += "#TabsToolbar::before { content: \"" + escapedName + "\"; " +
+ "color: rgba(255,255,255,.85); font-size: 13px; " +
+ "flex: 1; -moz-box-flex: 1; align-self: center; " +
+ "padding-inline-start: " + paddingStart + "; " +
+ iconCSS +
+ "-moz-window-dragging: drag; }\n"
+ }
+ if err := os.WriteFile(filepath.Join(chromeDir, "userChrome.css"), []byte(css), 0644); err != nil {
+ return err
+ }
+ userJS := "user_pref(\"toolkit.legacyUserProfileCustomizations.stylesheets\", true);\n" +
+ // Disable the 131+ sidebar revamp; without this it persists even when #sidebar-main is hidden.
+ "user_pref(\"sidebar.revamp\", false);\n" +
+ "user_pref(\"sidebar.main.tools\", \"\");\n" +
+ "user_pref(\"sidebar.verticalTabs\", false);\n" +
+ "user_pref(\"sidebar.visibility\", \"hide-sidebar\");\n" +
+ "user_pref(\"browser.ml.chat.enabled\", false);\n"
+ // Enable Firefox devtools when LORCA_DEVTOOLS is set; lost on every fresh-profile launch.
+ if os.Getenv("LORCA_DEVTOOLS") != "" {
+ userJS += "user_pref(\"devtools.chrome.enabled\", true);\n" +
+ "user_pref(\"devtools.debugger.remote-enabled\", true);\n"
+ }
+ return os.WriteFile(filepath.Join(dir, "user.js"), []byte(userJS), 0644)
+}
+
+// extractSmallPNGFromICO parses an ICO file and returns the raw bytes of the
+// smallest PNG-encoded image it contains, preferring 16x16. Modern .ico files
+// embed PNG images directly; older BMP-only ICOs return nil.
+func extractSmallPNGFromICO(path string) []byte {
+ data, err := os.ReadFile(path)
+ if err != nil || len(data) < 6 {
+ return nil
+ }
+ // ICO header: reserved(2) + type(2, must be 1) + count(2)
+ if binary.LittleEndian.Uint16(data[0:2]) != 0 || binary.LittleEndian.Uint16(data[2:4]) != 1 {
+ return nil
+ }
+ count := int(binary.LittleEndian.Uint16(data[4:6]))
+
+ var best []byte
+ bestSize := 0
+ // Each ICONDIRENTRY is 16 bytes starting at offset 6.
+ for i := 0; i < count; i++ {
+ base := 6 + i*16
+ if base+16 > len(data) {
+ break
+ }
+ w := int(data[base]) // 0 encodes 256
+ h := int(data[base+1]) // 0 encodes 256
+ imgSize := int(binary.LittleEndian.Uint32(data[base+8 : base+12]))
+ imgOffset := int(binary.LittleEndian.Uint32(data[base+12 : base+16]))
+ if imgOffset < 0 || imgSize < 8 || imgOffset+imgSize > len(data) {
+ continue
+ }
+ img := data[imgOffset : imgOffset+imgSize]
+ // PNG magic: \x89 P N G \r \n \x1a \n
+ if img[0] != 0x89 || img[1] != 'P' || img[2] != 'N' || img[3] != 'G' {
+ continue
+ }
+ size := w
+ if h > size {
+ size = h
+ }
+ if size == 0 {
+ size = 256
+ }
+ if size == 16 {
+ return img // exact match
+ }
+ if best == nil || size < bestSize {
+ best = img
+ bestSize = size
+ }
+ }
+ return best
}
diff --git a/ui_test.go b/ui_test.go
index 892502a..ccaa802 100644
--- a/ui_test.go
+++ b/ui_test.go
@@ -3,12 +3,13 @@ package lorca
import (
"errors"
"math/rand"
+ "os"
"strconv"
"testing"
)
func TestEval(t *testing.T) {
- ui, err := New("", "", 480, 320, "--headless")
+ ui, err := New("", "", "", 480, 320, "", "--headless")
if err != nil {
t.Fatal(err)
}
@@ -33,7 +34,7 @@ func TestEval(t *testing.T) {
}
func TestBind(t *testing.T) {
- ui, err := New("", "", 480, 320, "--headless")
+ ui, err := New("", "", "", 480, 320, "", "--headless")
if err != nil {
t.Fatal(err)
}
@@ -88,13 +89,13 @@ func TestBind(t *testing.T) {
}
func TestFunctionReturnTypes(t *testing.T) {
- ui, err := New("", "", 480, 320, "--headless")
+ ui, err := New("", "", "", 480, 320, "", "--headless")
if err != nil {
t.Fatal(err)
}
defer ui.Close()
- if err := ui.Bind("noResults", func() { return }); err != nil {
+ if err := ui.Bind("noResults", func() { /* Left empty to imply return : S1023 */ }); err != nil {
t.Fatal(err)
}
if err := ui.Bind("oneNonNilResult", func() interface{} { return 1 }); err != nil {
@@ -150,3 +151,40 @@ func TestFunctionReturnTypes(t *testing.T) {
t.Fatal(v)
}
}
+
+func TestNewWithBrowserChrome(t *testing.T) {
+ ui, err := NewWithBrowser("", "", "", 480, 320, BrowserChrome, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+ if n := ui.Eval(`2+3`).Int(); n != 5 {
+ t.Fatalf("expected 5, got %d", n)
+ }
+}
+
+func TestNewWithBrowserAuto(t *testing.T) {
+ ui, err := NewWithBrowser("", "", "", 480, 320, BrowserAuto, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+ if n := ui.Eval(`2+3`).Int(); n != 5 {
+ t.Fatalf("expected 5, got %d", n)
+ }
+}
+
+func TestNewWithBrowserFirefox(t *testing.T) {
+ ffPath, ok := os.LookupEnv("LORCAFIREFOX")
+ if !ok || ffPath == "" {
+ t.Skip("LORCAFIREFOX not set")
+ }
+ ui, err := NewWithBrowser("", "", ffPath, 480, 320, BrowserFirefox, "", "--headless")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ui.Close()
+ if n := ui.Eval(`2+3`).Int(); n != 5 {
+ t.Fatalf("expected 5, got %d", n)
+ }
+}
diff --git a/vendor/golang.org/x/net/LICENSE b/vendor/golang.org/x/net/LICENSE
new file mode 100644
index 0000000..2a7cf70
--- /dev/null
+++ b/vendor/golang.org/x/net/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2009 The Go Authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google LLC nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/vendor/golang.org/x/net/PATENTS b/vendor/golang.org/x/net/PATENTS
new file mode 100644
index 0000000..7330990
--- /dev/null
+++ b/vendor/golang.org/x/net/PATENTS
@@ -0,0 +1,22 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Go project.
+
+Google hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section)
+patent license to make, have made, use, offer to sell, sell, import,
+transfer and otherwise run, modify and propagate the contents of this
+implementation of Go, where such license applies only to those patent
+claims, both currently owned or controlled by Google and acquired in
+the future, licensable by Google that are necessarily infringed by this
+implementation of Go. This grant does not include claims that would be
+infringed only as a consequence of further modification of this
+implementation. If you or your agent or exclusive licensee institute or
+order or agree to the institution of patent litigation against any
+entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that this implementation of Go or any code incorporated within this
+implementation of Go constitutes direct or contributory patent
+infringement, or inducement of patent infringement, then any patent
+rights granted to you under this License for this implementation of Go
+shall terminate as of the date such litigation is filed.
diff --git a/vendor/golang.org/x/net/websocket/client.go b/vendor/golang.org/x/net/websocket/client.go
new file mode 100644
index 0000000..1e64157
--- /dev/null
+++ b/vendor/golang.org/x/net/websocket/client.go
@@ -0,0 +1,139 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package websocket
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// DialError is an error that occurs while dialling a websocket server.
+type DialError struct {
+ *Config
+ Err error
+}
+
+func (e *DialError) Error() string {
+ return "websocket.Dial " + e.Config.Location.String() + ": " + e.Err.Error()
+}
+
+// NewConfig creates a new WebSocket config for client connection.
+func NewConfig(server, origin string) (config *Config, err error) {
+ config = new(Config)
+ config.Version = ProtocolVersionHybi13
+ config.Location, err = url.ParseRequestURI(server)
+ if err != nil {
+ return
+ }
+ config.Origin, err = url.ParseRequestURI(origin)
+ if err != nil {
+ return
+ }
+ config.Header = http.Header(make(map[string][]string))
+ return
+}
+
+// NewClient creates a new WebSocket client connection over rwc.
+func NewClient(config *Config, rwc io.ReadWriteCloser) (ws *Conn, err error) {
+ br := bufio.NewReader(rwc)
+ bw := bufio.NewWriter(rwc)
+ err = hybiClientHandshake(config, br, bw)
+ if err != nil {
+ return
+ }
+ buf := bufio.NewReadWriter(br, bw)
+ ws = newHybiClientConn(config, buf, rwc)
+ return
+}
+
+// Dial opens a new client connection to a WebSocket.
+func Dial(url_, protocol, origin string) (ws *Conn, err error) {
+ config, err := NewConfig(url_, origin)
+ if err != nil {
+ return nil, err
+ }
+ if protocol != "" {
+ config.Protocol = []string{protocol}
+ }
+ return DialConfig(config)
+}
+
+var portMap = map[string]string{
+ "ws": "80",
+ "wss": "443",
+}
+
+func parseAuthority(location *url.URL) string {
+ if _, ok := portMap[location.Scheme]; ok {
+ if _, _, err := net.SplitHostPort(location.Host); err != nil {
+ return net.JoinHostPort(location.Host, portMap[location.Scheme])
+ }
+ }
+ return location.Host
+}
+
+// DialConfig opens a new client connection to a WebSocket with a config.
+func DialConfig(config *Config) (ws *Conn, err error) {
+ return config.DialContext(context.Background())
+}
+
+// DialContext opens a new client connection to a WebSocket, with context support for timeouts/cancellation.
+func (config *Config) DialContext(ctx context.Context) (*Conn, error) {
+ if config.Location == nil {
+ return nil, &DialError{config, ErrBadWebSocketLocation}
+ }
+ if config.Origin == nil {
+ return nil, &DialError{config, ErrBadWebSocketOrigin}
+ }
+
+ dialer := config.Dialer
+ if dialer == nil {
+ dialer = &net.Dialer{}
+ }
+
+ client, err := dialWithDialer(ctx, dialer, config)
+ if err != nil {
+ return nil, &DialError{config, err}
+ }
+
+ // Cleanup the connection if we fail to create the websocket successfully
+ success := false
+ defer func() {
+ if !success {
+ _ = client.Close()
+ }
+ }()
+
+ var ws *Conn
+ var wsErr error
+ doneConnecting := make(chan struct{})
+ go func() {
+ defer close(doneConnecting)
+ ws, err = NewClient(config, client)
+ if err != nil {
+ wsErr = &DialError{config, err}
+ }
+ }()
+
+ // The websocket.NewClient() function can block indefinitely, make sure that we
+ // respect the deadlines specified by the context.
+ select {
+ case <-ctx.Done():
+ // Force the pending operations to fail, terminating the pending connection attempt
+ _ = client.SetDeadline(time.Now())
+ <-doneConnecting // Wait for the goroutine that tries to establish the connection to finish
+ return nil, &DialError{config, ctx.Err()}
+ case <-doneConnecting:
+ if wsErr == nil {
+ success = true // Disarm the deferred connection cleanup
+ }
+ return ws, wsErr
+ }
+}
diff --git a/vendor/golang.org/x/net/websocket/dial.go b/vendor/golang.org/x/net/websocket/dial.go
new file mode 100644
index 0000000..8a2d83c
--- /dev/null
+++ b/vendor/golang.org/x/net/websocket/dial.go
@@ -0,0 +1,29 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package websocket
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+)
+
+func dialWithDialer(ctx context.Context, dialer *net.Dialer, config *Config) (conn net.Conn, err error) {
+ switch config.Location.Scheme {
+ case "ws":
+ conn, err = dialer.DialContext(ctx, "tcp", parseAuthority(config.Location))
+
+ case "wss":
+ tlsDialer := &tls.Dialer{
+ NetDialer: dialer,
+ Config: config.TlsConfig,
+ }
+
+ conn, err = tlsDialer.DialContext(ctx, "tcp", parseAuthority(config.Location))
+ default:
+ err = ErrBadScheme
+ }
+ return
+}
diff --git a/vendor/golang.org/x/net/websocket/hybi.go b/vendor/golang.org/x/net/websocket/hybi.go
new file mode 100644
index 0000000..c7e76cd
--- /dev/null
+++ b/vendor/golang.org/x/net/websocket/hybi.go
@@ -0,0 +1,583 @@
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package websocket
+
+// This file implements a protocol of hybi draft.
+// http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-17
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/rand"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+const (
+ websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
+
+ closeStatusNormal = 1000
+ closeStatusGoingAway = 1001
+ closeStatusProtocolError = 1002
+ closeStatusUnsupportedData = 1003
+ closeStatusFrameTooLarge = 1004
+ closeStatusNoStatusRcvd = 1005
+ closeStatusAbnormalClosure = 1006
+ closeStatusBadMessageData = 1007
+ closeStatusPolicyViolation = 1008
+ closeStatusTooBigData = 1009
+ closeStatusExtensionMismatch = 1010
+
+ maxControlFramePayloadLength = 125
+)
+
+var (
+ ErrBadMaskingKey = &ProtocolError{"bad masking key"}
+ ErrBadPongMessage = &ProtocolError{"bad pong message"}
+ ErrBadClosingStatus = &ProtocolError{"bad closing status"}
+ ErrUnsupportedExtensions = &ProtocolError{"unsupported extensions"}
+ ErrNotImplemented = &ProtocolError{"not implemented"}
+
+ handshakeHeader = map[string]bool{
+ "Host": true,
+ "Upgrade": true,
+ "Connection": true,
+ "Sec-Websocket-Key": true,
+ "Sec-Websocket-Origin": true,
+ "Sec-Websocket-Version": true,
+ "Sec-Websocket-Protocol": true,
+ "Sec-Websocket-Accept": true,
+ }
+)
+
+// A hybiFrameHeader is a frame header as defined in hybi draft.
+type hybiFrameHeader struct {
+ Fin bool
+ Rsv [3]bool
+ OpCode byte
+ Length int64
+ MaskingKey []byte
+
+ data *bytes.Buffer
+}
+
+// A hybiFrameReader is a reader for hybi frame.
+type hybiFrameReader struct {
+ reader io.Reader
+
+ header hybiFrameHeader
+ pos int64
+ length int
+}
+
+func (frame *hybiFrameReader) Read(msg []byte) (n int, err error) {
+ n, err = frame.reader.Read(msg)
+ if frame.header.MaskingKey != nil {
+ for i := 0; i < n; i++ {
+ msg[i] = msg[i] ^ frame.header.MaskingKey[frame.pos%4]
+ frame.pos++
+ }
+ }
+ return n, err
+}
+
+func (frame *hybiFrameReader) PayloadType() byte { return frame.header.OpCode }
+
+func (frame *hybiFrameReader) HeaderReader() io.Reader {
+ if frame.header.data == nil {
+ return nil
+ }
+ if frame.header.data.Len() == 0 {
+ return nil
+ }
+ return frame.header.data
+}
+
+func (frame *hybiFrameReader) TrailerReader() io.Reader { return nil }
+
+func (frame *hybiFrameReader) Len() (n int) { return frame.length }
+
+// A hybiFrameReaderFactory creates new frame reader based on its frame type.
+type hybiFrameReaderFactory struct {
+ *bufio.Reader
+}
+
+// NewFrameReader reads a frame header from the connection, and creates new reader for the frame.
+// See Section 5.2 Base Framing protocol for detail.
+// http://tools.ietf.org/html/draft-ietf-hybi-thewebsocketprotocol-17#section-5.2
+func (buf hybiFrameReaderFactory) NewFrameReader() (frame frameReader, err error) {
+ hybiFrame := new(hybiFrameReader)
+ frame = hybiFrame
+ var header []byte
+ var b byte
+ // First byte. FIN/RSV1/RSV2/RSV3/OpCode(4bits)
+ b, err = buf.ReadByte()
+ if err != nil {
+ return
+ }
+ header = append(header, b)
+ hybiFrame.header.Fin = ((header[0] >> 7) & 1) != 0
+ for i := 0; i < 3; i++ {
+ j := uint(6 - i)
+ hybiFrame.header.Rsv[i] = ((header[0] >> j) & 1) != 0
+ }
+ hybiFrame.header.OpCode = header[0] & 0x0f
+
+ // Second byte. Mask/Payload len(7bits)
+ b, err = buf.ReadByte()
+ if err != nil {
+ return
+ }
+ header = append(header, b)
+ mask := (b & 0x80) != 0
+ b &= 0x7f
+ lengthFields := 0
+ switch {
+ case b <= 125: // Payload length 7bits.
+ hybiFrame.header.Length = int64(b)
+ case b == 126: // Payload length 7+16bits
+ lengthFields = 2
+ case b == 127: // Payload length 7+64bits
+ lengthFields = 8
+ }
+ for i := 0; i < lengthFields; i++ {
+ b, err = buf.ReadByte()
+ if err != nil {
+ return
+ }
+ if lengthFields == 8 && i == 0 { // MSB must be zero when 7+64 bits
+ b &= 0x7f
+ }
+ header = append(header, b)
+ hybiFrame.header.Length = hybiFrame.header.Length*256 + int64(b)
+ }
+ if mask {
+ // Masking key. 4 bytes.
+ for i := 0; i < 4; i++ {
+ b, err = buf.ReadByte()
+ if err != nil {
+ return
+ }
+ header = append(header, b)
+ hybiFrame.header.MaskingKey = append(hybiFrame.header.MaskingKey, b)
+ }
+ }
+ hybiFrame.reader = io.LimitReader(buf.Reader, hybiFrame.header.Length)
+ hybiFrame.header.data = bytes.NewBuffer(header)
+ hybiFrame.length = len(header) + int(hybiFrame.header.Length)
+ return
+}
+
+// A HybiFrameWriter is a writer for hybi frame.
+type hybiFrameWriter struct {
+ writer *bufio.Writer
+
+ header *hybiFrameHeader
+}
+
+func (frame *hybiFrameWriter) Write(msg []byte) (n int, err error) {
+ var header []byte
+ var b byte
+ if frame.header.Fin {
+ b |= 0x80
+ }
+ for i := 0; i < 3; i++ {
+ if frame.header.Rsv[i] {
+ j := uint(6 - i)
+ b |= 1 << j
+ }
+ }
+ b |= frame.header.OpCode
+ header = append(header, b)
+ if frame.header.MaskingKey != nil {
+ b = 0x80
+ } else {
+ b = 0
+ }
+ lengthFields := 0
+ length := len(msg)
+ switch {
+ case length <= 125:
+ b |= byte(length)
+ case length < 65536:
+ b |= 126
+ lengthFields = 2
+ default:
+ b |= 127
+ lengthFields = 8
+ }
+ header = append(header, b)
+ for i := 0; i < lengthFields; i++ {
+ j := uint((lengthFields - i - 1) * 8)
+ b = byte((length >> j) & 0xff)
+ header = append(header, b)
+ }
+ if frame.header.MaskingKey != nil {
+ if len(frame.header.MaskingKey) != 4 {
+ return 0, ErrBadMaskingKey
+ }
+ header = append(header, frame.header.MaskingKey...)
+ frame.writer.Write(header)
+ data := make([]byte, length)
+ for i := range data {
+ data[i] = msg[i] ^ frame.header.MaskingKey[i%4]
+ }
+ frame.writer.Write(data)
+ err = frame.writer.Flush()
+ return length, err
+ }
+ frame.writer.Write(header)
+ frame.writer.Write(msg)
+ err = frame.writer.Flush()
+ return length, err
+}
+
+func (frame *hybiFrameWriter) Close() error { return nil }
+
+type hybiFrameWriterFactory struct {
+ *bufio.Writer
+ needMaskingKey bool
+}
+
+func (buf hybiFrameWriterFactory) NewFrameWriter(payloadType byte) (frame frameWriter, err error) {
+ frameHeader := &hybiFrameHeader{Fin: true, OpCode: payloadType}
+ if buf.needMaskingKey {
+ frameHeader.MaskingKey, err = generateMaskingKey()
+ if err != nil {
+ return nil, err
+ }
+ }
+ return &hybiFrameWriter{writer: buf.Writer, header: frameHeader}, nil
+}
+
+type hybiFrameHandler struct {
+ conn *Conn
+ payloadType byte
+}
+
+func (handler *hybiFrameHandler) HandleFrame(frame frameReader) (frameReader, error) {
+ if handler.conn.IsServerConn() {
+ // The client MUST mask all frames sent to the server.
+ if frame.(*hybiFrameReader).header.MaskingKey == nil {
+ handler.WriteClose(closeStatusProtocolError)
+ return nil, io.EOF
+ }
+ } else {
+ // The server MUST NOT mask all frames.
+ if frame.(*hybiFrameReader).header.MaskingKey != nil {
+ handler.WriteClose(closeStatusProtocolError)
+ return nil, io.EOF
+ }
+ }
+ if header := frame.HeaderReader(); header != nil {
+ io.Copy(io.Discard, header)
+ }
+ switch frame.PayloadType() {
+ case ContinuationFrame:
+ frame.(*hybiFrameReader).header.OpCode = handler.payloadType
+ case TextFrame, BinaryFrame:
+ handler.payloadType = frame.PayloadType()
+ case CloseFrame:
+ return nil, io.EOF
+ case PingFrame, PongFrame:
+ b := make([]byte, maxControlFramePayloadLength)
+ n, err := io.ReadFull(frame, b)
+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
+ return nil, err
+ }
+ io.Copy(io.Discard, frame)
+ if frame.PayloadType() == PingFrame {
+ if _, err := handler.WritePong(b[:n]); err != nil {
+ return nil, err
+ }
+ }
+ return nil, nil
+ }
+ return frame, nil
+}
+
+func (handler *hybiFrameHandler) WriteClose(status int) (err error) {
+ handler.conn.wio.Lock()
+ defer handler.conn.wio.Unlock()
+ w, err := handler.conn.frameWriterFactory.NewFrameWriter(CloseFrame)
+ if err != nil {
+ return err
+ }
+ msg := make([]byte, 2)
+ binary.BigEndian.PutUint16(msg, uint16(status))
+ _, err = w.Write(msg)
+ w.Close()
+ return err
+}
+
+func (handler *hybiFrameHandler) WritePong(msg []byte) (n int, err error) {
+ handler.conn.wio.Lock()
+ defer handler.conn.wio.Unlock()
+ w, err := handler.conn.frameWriterFactory.NewFrameWriter(PongFrame)
+ if err != nil {
+ return 0, err
+ }
+ n, err = w.Write(msg)
+ w.Close()
+ return n, err
+}
+
+// newHybiConn creates a new WebSocket connection speaking hybi draft protocol.
+func newHybiConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
+ if buf == nil {
+ br := bufio.NewReader(rwc)
+ bw := bufio.NewWriter(rwc)
+ buf = bufio.NewReadWriter(br, bw)
+ }
+ ws := &Conn{config: config, request: request, buf: buf, rwc: rwc,
+ frameReaderFactory: hybiFrameReaderFactory{buf.Reader},
+ frameWriterFactory: hybiFrameWriterFactory{
+ buf.Writer, request == nil},
+ PayloadType: TextFrame,
+ defaultCloseStatus: closeStatusNormal}
+ ws.frameHandler = &hybiFrameHandler{conn: ws}
+ return ws
+}
+
+// generateMaskingKey generates a masking key for a frame.
+func generateMaskingKey() (maskingKey []byte, err error) {
+ maskingKey = make([]byte, 4)
+ if _, err = io.ReadFull(rand.Reader, maskingKey); err != nil {
+ return
+ }
+ return
+}
+
+// generateNonce generates a nonce consisting of a randomly selected 16-byte
+// value that has been base64-encoded.
+func generateNonce() (nonce []byte) {
+ key := make([]byte, 16)
+ if _, err := io.ReadFull(rand.Reader, key); err != nil {
+ panic(err)
+ }
+ nonce = make([]byte, 24)
+ base64.StdEncoding.Encode(nonce, key)
+ return
+}
+
+// removeZone removes IPv6 zone identifier from host.
+// E.g., "[fe80::1%en0]:8080" to "[fe80::1]:8080"
+func removeZone(host string) string {
+ if !strings.HasPrefix(host, "[") {
+ return host
+ }
+ i := strings.LastIndex(host, "]")
+ if i < 0 {
+ return host
+ }
+ j := strings.LastIndex(host[:i], "%")
+ if j < 0 {
+ return host
+ }
+ return host[:j] + host[i:]
+}
+
+// getNonceAccept computes the base64-encoded SHA-1 of the concatenation of
+// the nonce ("Sec-WebSocket-Key" value) with the websocket GUID string.
+func getNonceAccept(nonce []byte) (expected []byte, err error) {
+ h := sha1.New()
+ if _, err = h.Write(nonce); err != nil {
+ return
+ }
+ if _, err = h.Write([]byte(websocketGUID)); err != nil {
+ return
+ }
+ expected = make([]byte, 28)
+ base64.StdEncoding.Encode(expected, h.Sum(nil))
+ return
+}
+
+// Client handshake described in draft-ietf-hybi-thewebsocket-protocol-17
+func hybiClientHandshake(config *Config, br *bufio.Reader, bw *bufio.Writer) (err error) {
+ bw.WriteString("GET " + config.Location.RequestURI() + " HTTP/1.1\r\n")
+
+ // According to RFC 6874, an HTTP client, proxy, or other
+ // intermediary must remove any IPv6 zone identifier attached
+ // to an outgoing URI.
+ bw.WriteString("Host: " + removeZone(config.Location.Host) + "\r\n")
+ bw.WriteString("Upgrade: websocket\r\n")
+ bw.WriteString("Connection: Upgrade\r\n")
+ nonce := generateNonce()
+ if config.handshakeData != nil {
+ nonce = []byte(config.handshakeData["key"])
+ }
+ bw.WriteString("Sec-WebSocket-Key: " + string(nonce) + "\r\n")
+ bw.WriteString("Origin: " + strings.ToLower(config.Origin.String()) + "\r\n")
+
+ if config.Version != ProtocolVersionHybi13 {
+ return ErrBadProtocolVersion
+ }
+
+ bw.WriteString("Sec-WebSocket-Version: " + fmt.Sprintf("%d", config.Version) + "\r\n")
+ if len(config.Protocol) > 0 {
+ bw.WriteString("Sec-WebSocket-Protocol: " + strings.Join(config.Protocol, ", ") + "\r\n")
+ }
+ // TODO(ukai): send Sec-WebSocket-Extensions.
+ err = config.Header.WriteSubset(bw, handshakeHeader)
+ if err != nil {
+ return err
+ }
+
+ bw.WriteString("\r\n")
+ if err = bw.Flush(); err != nil {
+ return err
+ }
+
+ resp, err := http.ReadResponse(br, &http.Request{Method: "GET"})
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 101 {
+ return ErrBadStatus
+ }
+ if strings.ToLower(resp.Header.Get("Upgrade")) != "websocket" ||
+ strings.ToLower(resp.Header.Get("Connection")) != "upgrade" {
+ return ErrBadUpgrade
+ }
+ expectedAccept, err := getNonceAccept(nonce)
+ if err != nil {
+ return err
+ }
+ if resp.Header.Get("Sec-WebSocket-Accept") != string(expectedAccept) {
+ return ErrChallengeResponse
+ }
+ if resp.Header.Get("Sec-WebSocket-Extensions") != "" {
+ return ErrUnsupportedExtensions
+ }
+ offeredProtocol := resp.Header.Get("Sec-WebSocket-Protocol")
+ if offeredProtocol != "" {
+ protocolMatched := false
+ for i := 0; i < len(config.Protocol); i++ {
+ if config.Protocol[i] == offeredProtocol {
+ protocolMatched = true
+ break
+ }
+ }
+ if !protocolMatched {
+ return ErrBadWebSocketProtocol
+ }
+ config.Protocol = []string{offeredProtocol}
+ }
+
+ return nil
+}
+
+// newHybiClientConn creates a client WebSocket connection after handshake.
+func newHybiClientConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser) *Conn {
+ return newHybiConn(config, buf, rwc, nil)
+}
+
+// A HybiServerHandshaker performs a server handshake using hybi draft protocol.
+type hybiServerHandshaker struct {
+ *Config
+ accept []byte
+}
+
+func (c *hybiServerHandshaker) ReadHandshake(buf *bufio.Reader, req *http.Request) (code int, err error) {
+ c.Version = ProtocolVersionHybi13
+ if req.Method != "GET" {
+ return http.StatusMethodNotAllowed, ErrBadRequestMethod
+ }
+ // HTTP version can be safely ignored.
+
+ if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" ||
+ !strings.Contains(strings.ToLower(req.Header.Get("Connection")), "upgrade") {
+ return http.StatusBadRequest, ErrNotWebSocket
+ }
+
+ key := req.Header.Get("Sec-Websocket-Key")
+ if key == "" {
+ return http.StatusBadRequest, ErrChallengeResponse
+ }
+ version := req.Header.Get("Sec-Websocket-Version")
+ switch version {
+ case "13":
+ c.Version = ProtocolVersionHybi13
+ default:
+ return http.StatusBadRequest, ErrBadWebSocketVersion
+ }
+ var scheme string
+ if req.TLS != nil {
+ scheme = "wss"
+ } else {
+ scheme = "ws"
+ }
+ c.Location, err = url.ParseRequestURI(scheme + "://" + req.Host + req.URL.RequestURI())
+ if err != nil {
+ return http.StatusBadRequest, err
+ }
+ protocol := strings.TrimSpace(req.Header.Get("Sec-Websocket-Protocol"))
+ if protocol != "" {
+ protocols := strings.Split(protocol, ",")
+ for i := 0; i < len(protocols); i++ {
+ c.Protocol = append(c.Protocol, strings.TrimSpace(protocols[i]))
+ }
+ }
+ c.accept, err = getNonceAccept([]byte(key))
+ if err != nil {
+ return http.StatusInternalServerError, err
+ }
+ return http.StatusSwitchingProtocols, nil
+}
+
+// Origin parses the Origin header in req.
+// If the Origin header is not set, it returns nil and nil.
+func Origin(config *Config, req *http.Request) (*url.URL, error) {
+ var origin string
+ switch config.Version {
+ case ProtocolVersionHybi13:
+ origin = req.Header.Get("Origin")
+ }
+ if origin == "" {
+ return nil, nil
+ }
+ return url.ParseRequestURI(origin)
+}
+
+func (c *hybiServerHandshaker) AcceptHandshake(buf *bufio.Writer) (err error) {
+ if len(c.Protocol) > 0 {
+ if len(c.Protocol) != 1 {
+ // You need choose a Protocol in Handshake func in Server.
+ return ErrBadWebSocketProtocol
+ }
+ }
+ buf.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
+ buf.WriteString("Upgrade: websocket\r\n")
+ buf.WriteString("Connection: Upgrade\r\n")
+ buf.WriteString("Sec-WebSocket-Accept: " + string(c.accept) + "\r\n")
+ if len(c.Protocol) > 0 {
+ buf.WriteString("Sec-WebSocket-Protocol: " + c.Protocol[0] + "\r\n")
+ }
+ // TODO(ukai): send Sec-WebSocket-Extensions.
+ if c.Header != nil {
+ err := c.Header.WriteSubset(buf, handshakeHeader)
+ if err != nil {
+ return err
+ }
+ }
+ buf.WriteString("\r\n")
+ return buf.Flush()
+}
+
+func (c *hybiServerHandshaker) NewServerConn(buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
+ return newHybiServerConn(c.Config, buf, rwc, request)
+}
+
+// newHybiServerConn returns a new WebSocket connection speaking hybi draft protocol.
+func newHybiServerConn(config *Config, buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) *Conn {
+ return newHybiConn(config, buf, rwc, request)
+}
diff --git a/vendor/golang.org/x/net/websocket/server.go b/vendor/golang.org/x/net/websocket/server.go
new file mode 100644
index 0000000..0895dea
--- /dev/null
+++ b/vendor/golang.org/x/net/websocket/server.go
@@ -0,0 +1,113 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package websocket
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+func newServerConn(rwc io.ReadWriteCloser, buf *bufio.ReadWriter, req *http.Request, config *Config, handshake func(*Config, *http.Request) error) (conn *Conn, err error) {
+ var hs serverHandshaker = &hybiServerHandshaker{Config: config}
+ code, err := hs.ReadHandshake(buf.Reader, req)
+ if err == ErrBadWebSocketVersion {
+ fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
+ fmt.Fprintf(buf, "Sec-WebSocket-Version: %s\r\n", SupportedProtocolVersion)
+ buf.WriteString("\r\n")
+ buf.WriteString(err.Error())
+ buf.Flush()
+ return
+ }
+ if err != nil {
+ fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
+ buf.WriteString("\r\n")
+ buf.WriteString(err.Error())
+ buf.Flush()
+ return
+ }
+ if handshake != nil {
+ err = handshake(config, req)
+ if err != nil {
+ code = http.StatusForbidden
+ fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
+ buf.WriteString("\r\n")
+ buf.Flush()
+ return
+ }
+ }
+ err = hs.AcceptHandshake(buf.Writer)
+ if err != nil {
+ code = http.StatusBadRequest
+ fmt.Fprintf(buf, "HTTP/1.1 %03d %s\r\n", code, http.StatusText(code))
+ buf.WriteString("\r\n")
+ buf.Flush()
+ return
+ }
+ conn = hs.NewServerConn(buf, rwc, req)
+ return
+}
+
+// Server represents a server of a WebSocket.
+type Server struct {
+ // Config is a WebSocket configuration for new WebSocket connection.
+ Config
+
+ // Handshake is an optional function in WebSocket handshake.
+ // For example, you can check, or don't check Origin header.
+ // Another example, you can select config.Protocol.
+ Handshake func(*Config, *http.Request) error
+
+ // Handler handles a WebSocket connection.
+ Handler
+}
+
+// ServeHTTP implements the http.Handler interface for a WebSocket
+func (s Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ s.serveWebSocket(w, req)
+}
+
+func (s Server) serveWebSocket(w http.ResponseWriter, req *http.Request) {
+ rwc, buf, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ panic("Hijack failed: " + err.Error())
+ }
+ // The server should abort the WebSocket connection if it finds
+ // the client did not send a handshake that matches with protocol
+ // specification.
+ defer rwc.Close()
+ conn, err := newServerConn(rwc, buf, req, &s.Config, s.Handshake)
+ if err != nil {
+ return
+ }
+ if conn == nil {
+ panic("unexpected nil conn")
+ }
+ s.Handler(conn)
+}
+
+// Handler is a simple interface to a WebSocket browser client.
+// It checks if Origin header is valid URL by default.
+// You might want to verify websocket.Conn.Config().Origin in the func.
+// If you use Server instead of Handler, you could call websocket.Origin and
+// check the origin in your Handshake func. So, if you want to accept
+// non-browser clients, which do not send an Origin header, set a
+// Server.Handshake that does not check the origin.
+type Handler func(*Conn)
+
+func checkOrigin(config *Config, req *http.Request) (err error) {
+ config.Origin, err = Origin(config, req)
+ if err == nil && config.Origin == nil {
+ return fmt.Errorf("null origin")
+ }
+ return err
+}
+
+// ServeHTTP implements the http.Handler interface for a WebSocket
+func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ s := Server{Handler: h, Handshake: checkOrigin}
+ s.serveWebSocket(w, req)
+}
diff --git a/vendor/golang.org/x/net/websocket/websocket.go b/vendor/golang.org/x/net/websocket/websocket.go
new file mode 100644
index 0000000..3448d20
--- /dev/null
+++ b/vendor/golang.org/x/net/websocket/websocket.go
@@ -0,0 +1,449 @@
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package websocket implements a client and server for the WebSocket protocol
+// as specified in RFC 6455.
+//
+// This package currently lacks some features found in an alternative
+// and more actively maintained WebSocket packages:
+//
+// - [github.com/gorilla/websocket]
+// - [github.com/coder/websocket]
+package websocket // import "golang.org/x/net/websocket"
+
+import (
+ "bufio"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+)
+
+const (
+ ProtocolVersionHybi13 = 13
+ ProtocolVersionHybi = ProtocolVersionHybi13
+ SupportedProtocolVersion = "13"
+
+ ContinuationFrame = 0
+ TextFrame = 1
+ BinaryFrame = 2
+ CloseFrame = 8
+ PingFrame = 9
+ PongFrame = 10
+ UnknownFrame = 255
+
+ DefaultMaxPayloadBytes = 32 << 20 // 32MB
+)
+
+// ProtocolError represents WebSocket protocol errors.
+type ProtocolError struct {
+ ErrorString string
+}
+
+func (err *ProtocolError) Error() string { return err.ErrorString }
+
+var (
+ ErrBadProtocolVersion = &ProtocolError{"bad protocol version"}
+ ErrBadScheme = &ProtocolError{"bad scheme"}
+ ErrBadStatus = &ProtocolError{"bad status"}
+ ErrBadUpgrade = &ProtocolError{"missing or bad upgrade"}
+ ErrBadWebSocketOrigin = &ProtocolError{"missing or bad WebSocket-Origin"}
+ ErrBadWebSocketLocation = &ProtocolError{"missing or bad WebSocket-Location"}
+ ErrBadWebSocketProtocol = &ProtocolError{"missing or bad WebSocket-Protocol"}
+ ErrBadWebSocketVersion = &ProtocolError{"missing or bad WebSocket Version"}
+ ErrChallengeResponse = &ProtocolError{"mismatch challenge/response"}
+ ErrBadFrame = &ProtocolError{"bad frame"}
+ ErrBadFrameBoundary = &ProtocolError{"not on frame boundary"}
+ ErrNotWebSocket = &ProtocolError{"not websocket protocol"}
+ ErrBadRequestMethod = &ProtocolError{"bad method"}
+ ErrNotSupported = &ProtocolError{"not supported"}
+)
+
+// ErrFrameTooLarge is returned by Codec's Receive method if payload size
+// exceeds limit set by Conn.MaxPayloadBytes
+var ErrFrameTooLarge = errors.New("websocket: frame payload size exceeds limit")
+
+// Addr is an implementation of net.Addr for WebSocket.
+type Addr struct {
+ *url.URL
+}
+
+// Network returns the network type for a WebSocket, "websocket".
+func (addr *Addr) Network() string { return "websocket" }
+
+// Config is a WebSocket configuration
+type Config struct {
+ // A WebSocket server address.
+ Location *url.URL
+
+ // A Websocket client origin.
+ Origin *url.URL
+
+ // WebSocket subprotocols.
+ Protocol []string
+
+ // WebSocket protocol version.
+ Version int
+
+ // TLS config for secure WebSocket (wss).
+ TlsConfig *tls.Config
+
+ // Additional header fields to be sent in WebSocket opening handshake.
+ Header http.Header
+
+ // Dialer used when opening websocket connections.
+ Dialer *net.Dialer
+
+ handshakeData map[string]string
+}
+
+// serverHandshaker is an interface to handle WebSocket server side handshake.
+type serverHandshaker interface {
+ // ReadHandshake reads handshake request message from client.
+ // Returns http response code and error if any.
+ ReadHandshake(buf *bufio.Reader, req *http.Request) (code int, err error)
+
+ // AcceptHandshake accepts the client handshake request and sends
+ // handshake response back to client.
+ AcceptHandshake(buf *bufio.Writer) (err error)
+
+ // NewServerConn creates a new WebSocket connection.
+ NewServerConn(buf *bufio.ReadWriter, rwc io.ReadWriteCloser, request *http.Request) (conn *Conn)
+}
+
+// frameReader is an interface to read a WebSocket frame.
+type frameReader interface {
+ // Reader is to read payload of the frame.
+ io.Reader
+
+ // PayloadType returns payload type.
+ PayloadType() byte
+
+ // HeaderReader returns a reader to read header of the frame.
+ HeaderReader() io.Reader
+
+ // TrailerReader returns a reader to read trailer of the frame.
+ // If it returns nil, there is no trailer in the frame.
+ TrailerReader() io.Reader
+
+ // Len returns total length of the frame, including header and trailer.
+ Len() int
+}
+
+// frameReaderFactory is an interface to creates new frame reader.
+type frameReaderFactory interface {
+ NewFrameReader() (r frameReader, err error)
+}
+
+// frameWriter is an interface to write a WebSocket frame.
+type frameWriter interface {
+ // Writer is to write payload of the frame.
+ io.WriteCloser
+}
+
+// frameWriterFactory is an interface to create new frame writer.
+type frameWriterFactory interface {
+ NewFrameWriter(payloadType byte) (w frameWriter, err error)
+}
+
+type frameHandler interface {
+ HandleFrame(frame frameReader) (r frameReader, err error)
+ WriteClose(status int) (err error)
+}
+
+// Conn represents a WebSocket connection.
+//
+// Multiple goroutines may invoke methods on a Conn simultaneously.
+type Conn struct {
+ config *Config
+ request *http.Request
+
+ buf *bufio.ReadWriter
+ rwc io.ReadWriteCloser
+
+ rio sync.Mutex
+ frameReaderFactory
+ frameReader
+
+ wio sync.Mutex
+ frameWriterFactory
+
+ frameHandler
+ PayloadType byte
+ defaultCloseStatus int
+
+ // MaxPayloadBytes limits the size of frame payload received over Conn
+ // by Codec's Receive method. If zero, DefaultMaxPayloadBytes is used.
+ MaxPayloadBytes int
+}
+
+// Read implements the io.Reader interface:
+// it reads data of a frame from the WebSocket connection.
+// if msg is not large enough for the frame data, it fills the msg and next Read
+// will read the rest of the frame data.
+// it reads Text frame or Binary frame.
+func (ws *Conn) Read(msg []byte) (n int, err error) {
+ ws.rio.Lock()
+ defer ws.rio.Unlock()
+again:
+ if ws.frameReader == nil {
+ frame, err := ws.frameReaderFactory.NewFrameReader()
+ if err != nil {
+ return 0, err
+ }
+ ws.frameReader, err = ws.frameHandler.HandleFrame(frame)
+ if err != nil {
+ return 0, err
+ }
+ if ws.frameReader == nil {
+ goto again
+ }
+ }
+ n, err = ws.frameReader.Read(msg)
+ if err == io.EOF {
+ if trailer := ws.frameReader.TrailerReader(); trailer != nil {
+ io.Copy(io.Discard, trailer)
+ }
+ ws.frameReader = nil
+ goto again
+ }
+ return n, err
+}
+
+// Write implements the io.Writer interface:
+// it writes data as a frame to the WebSocket connection.
+func (ws *Conn) Write(msg []byte) (n int, err error) {
+ ws.wio.Lock()
+ defer ws.wio.Unlock()
+ w, err := ws.frameWriterFactory.NewFrameWriter(ws.PayloadType)
+ if err != nil {
+ return 0, err
+ }
+ n, err = w.Write(msg)
+ w.Close()
+ return n, err
+}
+
+// Close implements the io.Closer interface.
+func (ws *Conn) Close() error {
+ err := ws.frameHandler.WriteClose(ws.defaultCloseStatus)
+ err1 := ws.rwc.Close()
+ if err != nil {
+ return err
+ }
+ return err1
+}
+
+// IsClientConn reports whether ws is a client-side connection.
+func (ws *Conn) IsClientConn() bool { return ws.request == nil }
+
+// IsServerConn reports whether ws is a server-side connection.
+func (ws *Conn) IsServerConn() bool { return ws.request != nil }
+
+// LocalAddr returns the WebSocket Origin for the connection for client, or
+// the WebSocket location for server.
+func (ws *Conn) LocalAddr() net.Addr {
+ if ws.IsClientConn() {
+ return &Addr{ws.config.Origin}
+ }
+ return &Addr{ws.config.Location}
+}
+
+// RemoteAddr returns the WebSocket location for the connection for client, or
+// the Websocket Origin for server.
+func (ws *Conn) RemoteAddr() net.Addr {
+ if ws.IsClientConn() {
+ return &Addr{ws.config.Location}
+ }
+ return &Addr{ws.config.Origin}
+}
+
+var errSetDeadline = errors.New("websocket: cannot set deadline: not using a net.Conn")
+
+// SetDeadline sets the connection's network read & write deadlines.
+func (ws *Conn) SetDeadline(t time.Time) error {
+ if conn, ok := ws.rwc.(net.Conn); ok {
+ return conn.SetDeadline(t)
+ }
+ return errSetDeadline
+}
+
+// SetReadDeadline sets the connection's network read deadline.
+func (ws *Conn) SetReadDeadline(t time.Time) error {
+ if conn, ok := ws.rwc.(net.Conn); ok {
+ return conn.SetReadDeadline(t)
+ }
+ return errSetDeadline
+}
+
+// SetWriteDeadline sets the connection's network write deadline.
+func (ws *Conn) SetWriteDeadline(t time.Time) error {
+ if conn, ok := ws.rwc.(net.Conn); ok {
+ return conn.SetWriteDeadline(t)
+ }
+ return errSetDeadline
+}
+
+// Config returns the WebSocket config.
+func (ws *Conn) Config() *Config { return ws.config }
+
+// Request returns the http request upgraded to the WebSocket.
+// It is nil for client side.
+func (ws *Conn) Request() *http.Request { return ws.request }
+
+// Codec represents a symmetric pair of functions that implement a codec.
+type Codec struct {
+ Marshal func(v interface{}) (data []byte, payloadType byte, err error)
+ Unmarshal func(data []byte, payloadType byte, v interface{}) (err error)
+}
+
+// Send sends v marshaled by cd.Marshal as single frame to ws.
+func (cd Codec) Send(ws *Conn, v interface{}) (err error) {
+ data, payloadType, err := cd.Marshal(v)
+ if err != nil {
+ return err
+ }
+ ws.wio.Lock()
+ defer ws.wio.Unlock()
+ w, err := ws.frameWriterFactory.NewFrameWriter(payloadType)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(data)
+ w.Close()
+ return err
+}
+
+// Receive receives single frame from ws, unmarshaled by cd.Unmarshal and stores
+// in v. The whole frame payload is read to an in-memory buffer; max size of
+// payload is defined by ws.MaxPayloadBytes. If frame payload size exceeds
+// limit, ErrFrameTooLarge is returned; in this case frame is not read off wire
+// completely. The next call to Receive would read and discard leftover data of
+// previous oversized frame before processing next frame.
+func (cd Codec) Receive(ws *Conn, v interface{}) (err error) {
+ ws.rio.Lock()
+ defer ws.rio.Unlock()
+ if ws.frameReader != nil {
+ _, err = io.Copy(io.Discard, ws.frameReader)
+ if err != nil {
+ return err
+ }
+ ws.frameReader = nil
+ }
+again:
+ frame, err := ws.frameReaderFactory.NewFrameReader()
+ if err != nil {
+ return err
+ }
+ frame, err = ws.frameHandler.HandleFrame(frame)
+ if err != nil {
+ return err
+ }
+ if frame == nil {
+ goto again
+ }
+ maxPayloadBytes := ws.MaxPayloadBytes
+ if maxPayloadBytes == 0 {
+ maxPayloadBytes = DefaultMaxPayloadBytes
+ }
+ if hf, ok := frame.(*hybiFrameReader); ok && hf.header.Length > int64(maxPayloadBytes) {
+ // payload size exceeds limit, no need to call Unmarshal
+ //
+ // set frameReader to current oversized frame so that
+ // the next call to this function can drain leftover
+ // data before processing the next frame
+ ws.frameReader = frame
+ return ErrFrameTooLarge
+ }
+ payloadType := frame.PayloadType()
+ data, err := io.ReadAll(frame)
+ if err != nil {
+ return err
+ }
+ return cd.Unmarshal(data, payloadType, v)
+}
+
+func marshal(v interface{}) (msg []byte, payloadType byte, err error) {
+ switch data := v.(type) {
+ case string:
+ return []byte(data), TextFrame, nil
+ case []byte:
+ return data, BinaryFrame, nil
+ }
+ return nil, UnknownFrame, ErrNotSupported
+}
+
+func unmarshal(msg []byte, payloadType byte, v interface{}) (err error) {
+ switch data := v.(type) {
+ case *string:
+ *data = string(msg)
+ return nil
+ case *[]byte:
+ *data = msg
+ return nil
+ }
+ return ErrNotSupported
+}
+
+/*
+Message is a codec to send/receive text/binary data in a frame on WebSocket connection.
+To send/receive text frame, use string type.
+To send/receive binary frame, use []byte type.
+
+Trivial usage:
+
+ import "websocket"
+
+ // receive text frame
+ var message string
+ websocket.Message.Receive(ws, &message)
+
+ // send text frame
+ message = "hello"
+ websocket.Message.Send(ws, message)
+
+ // receive binary frame
+ var data []byte
+ websocket.Message.Receive(ws, &data)
+
+ // send binary frame
+ data = []byte{0, 1, 2}
+ websocket.Message.Send(ws, data)
+*/
+var Message = Codec{marshal, unmarshal}
+
+func jsonMarshal(v interface{}) (msg []byte, payloadType byte, err error) {
+ msg, err = json.Marshal(v)
+ return msg, TextFrame, err
+}
+
+func jsonUnmarshal(msg []byte, payloadType byte, v interface{}) (err error) {
+ return json.Unmarshal(msg, v)
+}
+
+/*
+JSON is a codec to send/receive JSON data in a frame from a WebSocket connection.
+
+Trivial usage:
+
+ import "websocket"
+
+ type T struct {
+ Msg string
+ Count int
+ }
+
+ // receive JSON type T
+ var data T
+ websocket.JSON.Receive(ws, &data)
+
+ // send JSON type T
+ websocket.JSON.Send(ws, data)
+*/
+var JSON = Codec{jsonMarshal, jsonUnmarshal}
diff --git a/vendor/modules.txt b/vendor/modules.txt
new file mode 100644
index 0000000..5e78f75
--- /dev/null
+++ b/vendor/modules.txt
@@ -0,0 +1,3 @@
+# golang.org/x/net v0.49.0
+## explicit; go 1.24.0
+golang.org/x/net/websocket