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 -[![Build Status](https://img.shields.io/github/workflow/status/zserge/lorca/CI%20Pipeline)](https://github.com/zserge/lorca) -[![GoDoc](https://godoc.org/github.com/zserge/lorca?status.svg)](https://godoc.org/github.com/zserge/lorca) -[![Go Report Card](https://goreportcard.com/badge/github.com/zserge/lorca)](https://goreportcard.com/report/github.com/zserge/lorca) +[![Build Status](https://img.shields.io/github/actions/workflow/status/DavidArthurCole/lorca/ci.yml?branch=master)](https://github.com/DavidArthurCole/lorca) +[![GoDoc](https://godoc.org/github.com/DavidArthurCole/lorca?status.svg)](https://godoc.org/github.com/DavidArthurCole/lorca) +[![Go Report Card](https://goreportcard.com/badge/github.com/DavidArthurCole/lorca)](https://goreportcard.com/report/github.com/DavidArthurCole/lorca)
-Lorca +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 -Description: Example for Lorca GUI toolkit -EOF - -dpkg-deb --build $APPDIR diff --git a/examples/counter/build-macos.sh b/examples/counter/build-macos.sh deleted file mode 100755 index 43f97f5..0000000 --- a/examples/counter/build-macos.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh - -APP="Example.app" -mkdir -p $APP/Contents/{MacOS,Resources} -go build -o $APP/Contents/MacOS/lorca-example -cat > $APP/Contents/Info.plist << EOF - - - - - CFBundleExecutable - lorca-example - CFBundleIconFile - icon.icns - CFBundleIdentifier - com.zserge.lorca.example - - -EOF -cp icons/icon.icns $APP/Contents/Resources/icon.icns -find $APP diff --git a/examples/counter/build-windows.bat b/examples/counter/build-windows.bat deleted file mode 100644 index 3522752..0000000 --- a/examples/counter/build-windows.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -go generate -go build -ldflags "-H windowsgui" -o lorca-example.exe diff --git a/examples/counter/counter.gif b/examples/counter/counter.gif deleted file mode 100644 index 2497ed5..0000000 Binary files a/examples/counter/counter.gif and /dev/null differ diff --git a/examples/counter/icons/icon-256.png b/examples/counter/icons/icon-256.png deleted file mode 100644 index 7c0fccf..0000000 Binary files a/examples/counter/icons/icon-256.png and /dev/null differ diff --git a/examples/counter/icons/icon.icns b/examples/counter/icons/icon.icns deleted file mode 100644 index 58689ff..0000000 Binary files a/examples/counter/icons/icon.icns and /dev/null differ diff --git a/examples/counter/icons/icon.png b/examples/counter/icons/icon.png deleted file mode 100644 index 418861d..0000000 Binary files a/examples/counter/icons/icon.png and /dev/null differ diff --git a/examples/counter/main.go b/examples/counter/main.go deleted file mode 100644 index 8aa272d..0000000 --- a/examples/counter/main.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -import ( - "embed" - "fmt" - "log" - "net" - "net/http" - "os" - "os/signal" - "runtime" - "sync" - - "github.com/zserge/lorca" -) - -//go:embed www -var fs embed.FS - -// Go types that are bound to the UI must be thread-safe, because each binding -// is executed in its own goroutine. In this simple case we may use atomic -// operations, but for more complex cases one should use proper synchronization. -type counter struct { - sync.Mutex - count int -} - -func (c *counter) Add(n int) { - c.Lock() - defer c.Unlock() - c.count = c.count + n -} - -func (c *counter) Value() int { - c.Lock() - defer c.Unlock() - return c.count -} - -func main() { - args := []string{} - if runtime.GOOS == "linux" { - args = append(args, "--class=Lorca") - } - ui, err := lorca.New("", "", 480, 320, args...) - if err != nil { - log.Fatal(err) - } - defer ui.Close() - - // A simple way to know when UI is ready (uses body.onload event in JS) - ui.Bind("start", func() { - log.Println("UI is ready") - }) - - // Create and bind Go object to the UI - c := &counter{} - ui.Bind("counterAdd", c.Add) - ui.Bind("counterValue", c.Value) - - // Load HTML. - // You may also use `data:text/html,` approach to load initial HTML, - // e.g: ui.Load("data:text/html," + url.PathEscape(html)) - - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - log.Fatal(err) - } - defer ln.Close() - go http.Serve(ln, http.FileServer(http.FS(fs))) - ui.Load(fmt.Sprintf("http://%s/www", ln.Addr())) - - // You may use console.log to debug your JS code, it will be printed via - // log.Println(). Also exceptions are printed in a similar manner. - ui.Eval(` - console.log("Hello, world!"); - console.log('Multiple values:', [1, false, {"x":5}]); - `) - - // Wait until the interrupt signal arrives or browser window is closed - sigc := make(chan os.Signal) - signal.Notify(sigc, os.Interrupt) - select { - case <-sigc: - case <-ui.Done(): - } - - log.Println("exiting...") -} diff --git a/examples/counter/www/favicon.png b/examples/counter/www/favicon.png deleted file mode 100644 index 82ff856..0000000 Binary files a/examples/counter/www/favicon.png and /dev/null differ diff --git a/examples/counter/www/index.html b/examples/counter/www/index.html deleted file mode 100644 index 467b8dc..0000000 --- a/examples/counter/www/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - Counter - - - - - -

-
-
-
+1
-
-1
-
-
- - - - - diff --git a/examples/hello/main.go b/examples/hello/main.go deleted file mode 100644 index 07589db..0000000 --- a/examples/hello/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "log" - "net/url" - - "github.com/zserge/lorca" -) - -func main() { - // Create UI with basic HTML passed via data URI - ui, err := lorca.New("data:text/html,"+url.PathEscape(` - - Hello -

Hello, world!

- - `), "", 480, 320) - if err != nil { - log.Fatal(err) - } - defer ui.Close() - // Wait until UI window is closed - <-ui.Done() -} 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