diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 068e8be..1dde479 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,19 +35,22 @@ jobs: - run: CGO_ENABLED=0 go build ./... - name: Test (whole module) run: CGO_ENABLED=0 go test ./... - - name: 100% coverage gate (internal/x11) + - name: 100% coverage gate (internal/x11 + internal/wayland) shell: bash - # -race needs cgo, so this step (and only this step) enables it. The - # library stays CGO-free, proven by the CGO=0 arch matrix below. + # -race needs cgo, so this step (and only this step) enables it. Both + # sovereign protocol layers stay CGO-free, proven by the CGO=0 arch + # matrix below. env: CGO_ENABLED: "1" run: | - go test -race -timeout 300s -coverprofile=cover.out ./internal/x11/ - go tool cover -func=cover.out | tail -n 40 - cov=$(go tool cover -func=cover.out | tail -1 | awk '{print $3}' | tr -d '%') - echo "internal/x11 coverage: ${cov}%" - awk -v t="$cov" 'BEGIN { exit !(t+0 >= 100.0) }' || { - echo "::error::internal/x11 coverage ${cov}% < 100%"; exit 1; } + for pkg in internal/x11 internal/wayland; do + go test -race -timeout 300s -coverprofile=cover.out "./$pkg/" + go tool cover -func=cover.out | tail -n 60 + cov=$(go tool cover -func=cover.out | tail -1 | awk '{print $3}' | tr -d '%') + echo "$pkg coverage: ${cov}%" + awk -v t="$cov" 'BEGIN { exit !(t+0 >= 100.0) }' || { + echo "::error::$pkg coverage ${cov}% < 100%"; exit 1; } + done # The macOS lane above also proves the non-Linux stub (Open -> ErrUnsupported) # builds and passes. Named here for clarity of intent. @@ -117,7 +120,7 @@ jobs: - name: Run live integration test under Xvfb env: WINDOW_X11_INTEGRATION: "1" - run: xvfb-run -s "-screen 0 1024x768x24" go test -tags=integration -run TestLive -v ./... + run: xvfb-run -s "-screen 0 1024x768x24" go test -tags=integration -run TestLiveX11 -v ./... - name: Upload the captured window as a build artifact if: always() uses: actions/upload-artifact@v4 @@ -125,3 +128,56 @@ jobs: name: live-capture path: live-capture.png if-no-files-found: warn + + # Live Wayland proof: open a real xdg-shell toplevel on a headless wlroots + # compositor (sway), present a known test pattern through a wl_shm buffer, + # capture the output with grim and assert sampled pixels. Input is attempted + # via wtype (virtual-keyboard) and asserted when the headless seat exposes a + # keyboard; otherwise it is proven by the in-process fake-compositor test. + live-wayland: + name: live Wayland (sway headless) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version: stable + - name: Install a headless Wayland compositor + capture/input tools + run: sudo apt-get update && sudo apt-get install -y sway grim wtype dbus + - name: Run live integration test under sway (headless) + shell: bash + run: | + set -x + export XDG_RUNTIME_DIR="$(mktemp -d)" + chmod 700 "$XDG_RUNTIME_DIR" + cat > sway.conf <<'CONF' + output HEADLESS-1 resolution 800x600 + default_border none + default_floating_border none + gaps inner 0 + gaps outer 0 + CONF + export WLR_BACKENDS=headless + export WLR_RENDERER=pixman + export WLR_LIBINPUT_NO_DEVICES=1 + export LIBSEAT_BACKEND=noop + export WLR_HEADLESS_OUTPUTS=1 + dbus-run-session -- sway -c sway.conf & + # Wait for sway to create its Wayland socket. + sock="" + for i in $(seq 1 60); do + sock=$(ls "$XDG_RUNTIME_DIR"/wayland-* 2>/dev/null | grep -v '\.lock' | head -1 || true) + [ -n "$sock" ] && break + sleep 0.5 + done + if [ -z "$sock" ]; then echo "::error::sway Wayland socket never appeared"; exit 1; fi + export WAYLAND_DISPLAY="$(basename "$sock")" + echo "using WAYLAND_DISPLAY=$WAYLAND_DISPLAY in $XDG_RUNTIME_DIR" + WINDOW_WAYLAND_INTEGRATION=1 go test -tags=integration -run TestLiveWayland -v ./... + - name: Upload the captured window as a build artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: live-wayland-capture + path: live-wayland-capture.png + if-no-files-found: warn diff --git a/internal/wayland/conn.go b/internal/wayland/conn.go new file mode 100644 index 0000000..c46da96 --- /dev/null +++ b/internal/wayland/conn.go @@ -0,0 +1,352 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "fmt" + "net" + "sync" +) + +// handler dispatches one event (identified by opcode) for a single object, +// reading its arguments from d. Returning an error is fatal to the session. +type handler func(opcode uint16, d *decoder) error + +// Conn is a Wayland connection: the object table, the request encoder and +// the event dispatcher over a transport. It is transport-agnostic — the +// same machine drives a real UNIX socket in production and an in-process +// fake compositor in tests. +type Conn struct { + t transport + order ByteOrder + + wmu sync.Mutex // serialises request writes + nextID uint32 // next client-allocated object id + handlers map[uint32]handler // object id -> event dispatcher + + display *Display + err error // latched fatal protocol error (from wl_display.error) +} + +// displayID is the well-known object id of the wl_display singleton; it is +// implicitly present on every connection before any request is sent. +const displayID = 1 + +// firstClientID is the first object id a client may allocate (id 1 is the +// display). +const firstClientID = 2 + +// NewConn builds a connection over t using the given wire byte order and +// installs the wl_display singleton. Order is normally NativeOrder. +func NewConn(t transport, order ByteOrder) *Conn { + c := &Conn{ + t: t, + order: order, + nextID: firstClientID, + handlers: make(map[uint32]handler), + } + c.display = newDisplay(c) + return c +} + +// New builds a connection over a dialed UNIX-domain socket using the host's +// native wire byte order — the production entry point for the window layer. +func New(c *net.UnixConn) *Conn { + return NewConn(newUnixTransport(c, NativeOrder), NativeOrder) +} + +// Display returns the wl_display singleton. +func (c *Conn) Display() *Display { return c.display } + +// Close releases the underlying transport. +func (c *Conn) Close() error { return c.t.Close() } + +// Err returns the latched fatal protocol error, if any. +func (c *Conn) Err() error { return c.err } + +// allocID reserves a fresh client object id. +func (c *Conn) allocID() uint32 { + id := c.nextID + c.nextID++ + return id +} + +// register installs an event handler for object id. +func (c *Conn) register(id uint32, h handler) { c.handlers[id] = h } + +// unregister removes an object's handler (after it is destroyed). +func (c *Conn) unregister(id uint32) { delete(c.handlers, id) } + +// send frames and writes one request: the 8-byte header (object id, then +// size<<16|opcode) followed by the already-encoded, already-padded body, +// carrying fds out-of-band. +func (c *Conn) send(objID uint32, opcode uint16, body []byte, fds []int) error { + total := 8 + len(body) + e := newEncoder(c.order) + e.putU32(objID) + e.putU32(uint32(opcode) | uint32(total)<<16) + e.putBytes(body) + + c.wmu.Lock() + defer c.wmu.Unlock() + return c.t.write(e.buf, fds) +} + +// recvFD returns the next file descriptor the compositor passed over +// SCM_RIGHTS, oldest first. Handlers for events carrying an fd (e.g. +// wl_keyboard.keymap) call it in argument order. +func (c *Conn) recvFD() (int, bool) { return c.t.popFD() } + +// Dispatch reads and delivers exactly one event. Events for objects with +// no handler (e.g. an object destroyed after the compositor queued an +// event for it) are read and discarded. A latched protocol error is +// returned in preference to anything else. +func (c *Conn) Dispatch() error { + if c.err != nil { + return c.err + } + msg, err := c.t.read() + if err != nil { + return err + } + d := newDecoder(c.order, msg) + objID := d.getU32() + word := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated message header") + } + opcode := uint16(word & 0xffff) + h := c.handlers[objID] + if h == nil { + return nil + } + if err := h(opcode, d); err != nil { + return err + } + return c.err +} + +// Roundtrip issues a wl_display.sync and dispatches events until the +// resulting callback fires, i.e. until the compositor has processed every +// request sent before the sync. It is the Wayland analogue of an X11 +// round-tripping request. +func (c *Conn) Roundtrip() error { + cb, err := c.display.sync() + if err != nil { + return err + } + for !cb.done { + if err := c.Dispatch(); err != nil { + return err + } + } + return nil +} + +// --- wl_display ----------------------------------------------------------- + +// Display is the wl_display singleton (object id 1): the root of every +// connection. It creates the registry and issues synchronisation +// callbacks, and it is the sink for global protocol errors. +type Display struct { + conn *Conn + id uint32 +} + +// wl_display request opcodes. +const ( + displayReqSync = 0 + displayReqGetRegistry = 1 +) + +// wl_display event opcodes. +const ( + displayEvtError = 0 + displayEvtDeleteID = 1 +) + +// newDisplay installs the wl_display handler at the well-known id. +func newDisplay(c *Conn) *Display { + d := &Display{conn: c, id: displayID} + c.register(d.id, d.handle) + return d +} + +// handle dispatches wl_display events. error latches a fatal error; +// delete_id acknowledges server-side id recycling (the id becomes free, +// but this sovereign client never reuses ids, so it only unregisters). +func (d *Display) handle(opcode uint16, dec *decoder) error { + switch opcode { + case displayEvtError: + objID := dec.getU32() + code := dec.getU32() + msg := dec.getString() + if !dec.ok { + return fmt.Errorf("wayland: truncated wl_display.error") + } + d.conn.err = fmt.Errorf("wayland: protocol error on object %d (code %d): %s", objID, code, msg) + return d.conn.err + case displayEvtDeleteID: + id := dec.getU32() + if !dec.ok { + return fmt.Errorf("wayland: truncated wl_display.delete_id") + } + d.conn.unregister(id) + return nil + default: + return nil + } +} + +// sync issues wl_display.sync, returning the callback that fires once the +// compositor has processed all prior requests. +func (d *Display) sync() (*Callback, error) { + cb := newCallback(d.conn) + e := newEncoder(d.conn.order) + e.putU32(cb.id) + if err := d.conn.send(d.id, displayReqSync, e.buf, nil); err != nil { + return nil, err + } + return cb, nil +} + +// GetRegistry issues wl_display.get_registry and returns the registry proxy. +func (d *Display) GetRegistry() (*Registry, error) { + r := &Registry{conn: d.conn, id: d.conn.allocID()} + d.conn.register(r.id, r.handle) + e := newEncoder(d.conn.order) + e.putU32(r.id) + if err := d.conn.send(d.id, displayReqGetRegistry, e.buf, nil); err != nil { + return nil, err + } + return r, nil +} + +// --- wl_callback ---------------------------------------------------------- + +// Callback is a one-shot wl_callback: it fires its done event once and is +// then finished. +type Callback struct { + conn *Conn + id uint32 + done bool + data uint32 +} + +// wl_callback event opcode. +const callbackEvtDone = 0 + +// newCallback allocates and registers a callback object. +func newCallback(c *Conn) *Callback { + cb := &Callback{conn: c, id: c.allocID()} + c.register(cb.id, cb.handle) + return cb +} + +// handle records the done event and retires the callback. +func (cb *Callback) handle(opcode uint16, d *decoder) error { + if opcode != callbackEvtDone { + return nil + } + cb.data = d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_callback.done") + } + cb.done = true + cb.conn.unregister(cb.id) + return nil +} + +// --- wl_registry ---------------------------------------------------------- + +// Global is one advertised global: a compositor-assigned name, the +// interface it implements and the maximum version offered. +type Global struct { + Name uint32 + Interface string + Version uint32 +} + +// Registry is wl_registry: it enumerates the compositor's globals and binds +// them into interface proxies. +type Registry struct { + conn *Conn + id uint32 + globals []Global +} + +// wl_registry request opcode. +const registryReqBind = 0 + +// wl_registry event opcodes. +const ( + registryEvtGlobal = 0 + registryEvtGlobalRemove = 1 +) + +// handle dispatches wl_registry events, maintaining the live global list. +func (r *Registry) handle(opcode uint16, d *decoder) error { + switch opcode { + case registryEvtGlobal: + g := Global{Name: d.getU32(), Interface: d.getString(), Version: d.getU32()} + if !d.ok { + return fmt.Errorf("wayland: truncated wl_registry.global") + } + r.globals = append(r.globals, g) + return nil + case registryEvtGlobalRemove: + name := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_registry.global_remove") + } + r.remove(name) + return nil + default: + return nil + } +} + +// remove drops the global with the given name, if present. +func (r *Registry) remove(name uint32) { + for i := range r.globals { + if r.globals[i].Name == name { + r.globals = append(r.globals[:i], r.globals[i+1:]...) + return + } + } +} + +// Globals returns a copy of the currently advertised globals. +func (r *Registry) Globals() []Global { + return append([]Global(nil), r.globals...) +} + +// Find returns the advertised global for the given interface name and +// whether it was found. When several versions are advertised the first is +// returned (compositors advertise one global per interface). +func (r *Registry) Find(iface string) (Global, bool) { + for _, g := range r.globals { + if g.Interface == iface { + return g, true + } + } + return Global{}, false +} + +// bind issues wl_registry.bind for the named global at the negotiated +// version, allocating and returning the new object's id. The caller wraps +// the id in the appropriate interface proxy and registers its handler. +func (r *Registry) bind(name uint32, iface string, version uint32) (uint32, error) { + id := r.conn.allocID() + e := newEncoder(r.conn.order) + e.putU32(name) + e.putString(iface) + e.putU32(version) + e.putU32(id) + if err := r.conn.send(r.id, registryReqBind, e.buf, nil); err != nil { + return 0, err + } + return id, nil +} diff --git a/internal/wayland/conn_test.go b/internal/wayland/conn_test.go new file mode 100644 index 0000000..227335f --- /dev/null +++ b/internal/wayland/conn_test.go @@ -0,0 +1,328 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "errors" + "testing" +) + +func TestNewConnBasics(t *testing.T) { + st := &stubTransport{} + c := NewConn(st, binary.LittleEndian) + if c.Display() == nil || c.Display().id != displayID { + t.Fatal("display singleton missing") + } + if got := c.allocID(); got != firstClientID { + t.Fatalf("first allocID = %d, want %d", got, firstClientID) + } + if got := c.allocID(); got != firstClientID+1 { + t.Fatalf("second allocID = %d", got) + } + if c.Err() != nil { + t.Fatalf("fresh conn Err = %v", c.Err()) + } + if err := c.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !st.closed { + t.Error("Close should close the transport") + } +} + +func TestNewOverSocket(t *testing.T) { + cli, srv := socketPair(t) + defer srv.Close() + c := New(cli) + defer c.Close() + if c.order != NativeOrder { + t.Error("New should use the native byte order") + } + if c.Display() == nil { + t.Error("New should install wl_display") + } +} + +// TestRegistryHandshake drives the real socket path end to end: get the +// registry, receive two globals, round-trip to the sync callback, and +// verify Find/Globals. +func TestRegistryHandshake(t *testing.T) { + bothOrders(t, func(t *testing.T, order ByteOrder) { + conn, fs := newTestConn(t, order) + serverErr := make(chan error, 1) + go func() { + // get_registry + obj, op, d, err := fs.readReq() + if err != nil { + serverErr <- err + return + } + if obj != displayID || op != displayReqGetRegistry { + serverErr <- errUnexpected("get_registry", obj, op) + return + } + regID := d.getU32() + g1 := bodyOf(order, func(e *encoder) { e.putU32(1); e.putString("wl_compositor"); e.putU32(4) }) + g2 := bodyOf(order, func(e *encoder) { e.putU32(2); e.putString("wl_shm"); e.putU32(1) }) + _ = fs.sendEvt(regID, registryEvtGlobal, g1) + _ = fs.sendEvt(regID, registryEvtGlobal, g2) + // sync + obj, op, d, err = fs.readReq() + if err != nil { + serverErr <- err + return + } + if obj != displayID || op != displayReqSync { + serverErr <- errUnexpected("sync", obj, op) + return + } + cbID := d.getU32() + _ = fs.sendEvt(cbID, callbackEvtDone, bodyOf(order, func(e *encoder) { e.putU32(0) })) + serverErr <- nil + }() + + reg, err := conn.Display().GetRegistry() + if err != nil { + t.Fatalf("GetRegistry: %v", err) + } + if err := conn.Roundtrip(); err != nil { + t.Fatalf("Roundtrip: %v", err) + } + if err := <-serverErr; err != nil { + t.Fatalf("server: %v", err) + } + if g, ok := reg.Find("wl_compositor"); !ok || g.Name != 1 || g.Version != 4 { + t.Fatalf("Find(wl_compositor) = %+v ok=%v", g, ok) + } + if _, ok := reg.Find("nope"); ok { + t.Error("Find of absent interface should be false") + } + if len(reg.Globals()) != 2 { + t.Fatalf("Globals len = %d, want 2", len(reg.Globals())) + } + }) +} + +func errUnexpected(what string, obj uint32, op uint16) error { + return errors.New("server: unexpected " + what) +} + +func TestRegistryBind(t *testing.T) { + order := binary.LittleEndian + conn, fs := newTestConn(t, order) + reg := &Registry{conn: conn, id: 2} + id, err := reg.bind(5, "wl_compositor", 4) + if err != nil { + t.Fatalf("bind: %v", err) + } + if id != firstClientID { + t.Fatalf("bind id = %d, want %d", id, firstClientID) + } + obj, op, d, err := fs.readReq() + if err != nil { + t.Fatalf("readReq: %v", err) + } + if obj != 2 || op != registryReqBind { + t.Fatalf("bind req obj=%d op=%d", obj, op) + } + if name := d.getU32(); name != 5 { + t.Errorf("bind name = %d, want 5", name) + } + if iface := d.getString(); iface != "wl_compositor" { + t.Errorf("bind iface = %q", iface) + } + if ver := d.getU32(); ver != 4 { + t.Errorf("bind version = %d", ver) + } + if newID := d.getU32(); newID != id { + t.Errorf("bind new_id = %d, want %d", newID, id) + } +} + +func TestRegistryGlobalRemove(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + reg := &Registry{conn: c, id: 2} + // Two globals, then remove the first. + if err := reg.handle(registryEvtGlobal, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(1); e.putString("a"); e.putU32(1) }))); err != nil { + t.Fatal(err) + } + if err := reg.handle(registryEvtGlobal, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(2); e.putString("b"); e.putU32(1) }))); err != nil { + t.Fatal(err) + } + if err := reg.handle(registryEvtGlobalRemove, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(1) }))); err != nil { + t.Fatal(err) + } + if _, ok := reg.Find("a"); ok { + t.Error("global a should be removed") + } + if _, ok := reg.Find("b"); !ok { + t.Error("global b should remain") + } + // Removing an unknown name is a no-op. + reg.remove(999) + // An unknown opcode is ignored. + if err := reg.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown registry opcode = %v", err) + } +} + +func TestRegistryHandleTruncated(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + reg := &Registry{conn: c, id: 2} + if err := reg.handle(registryEvtGlobal, newDecoder(order, nil)); err == nil { + t.Error("truncated global should error") + } + if err := reg.handle(registryEvtGlobalRemove, newDecoder(order, nil)); err == nil { + t.Error("truncated global_remove should error") + } +} + +func TestDisplayError(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + body := bodyOf(order, func(e *encoder) { e.putU32(7); e.putU32(3); e.putString("bad object") }) + err := c.display.handle(displayEvtError, newDecoder(order, body)) + if err == nil { + t.Fatal("wl_display.error should return an error") + } + if c.Err() == nil { + t.Fatal("wl_display.error should latch Conn.Err") + } + // Once latched, Dispatch surfaces it immediately. + if got := c.Dispatch(); got == nil { + t.Error("Dispatch after latched error should return it") + } + // Roundtrip too. + if got := c.Roundtrip(); got == nil { + t.Error("Roundtrip after latched error should return it") + } +} + +func TestDisplayErrorTruncated(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + if err := c.display.handle(displayEvtError, newDecoder(order, nil)); err == nil { + t.Error("truncated error event should error") + } +} + +func TestDisplayDeleteID(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + c.register(42, func(uint16, *decoder) error { return nil }) + body := bodyOf(order, func(e *encoder) { e.putU32(42) }) + if err := c.display.handle(displayEvtDeleteID, newDecoder(order, body)); err != nil { + t.Fatalf("delete_id: %v", err) + } + if _, ok := c.handlers[42]; ok { + t.Error("delete_id should unregister the object") + } + // Truncated delete_id errors. + if err := c.display.handle(displayEvtDeleteID, newDecoder(order, nil)); err == nil { + t.Error("truncated delete_id should error") + } + // Unknown display opcode is ignored. + if err := c.display.handle(77, newDecoder(order, nil)); err != nil { + t.Errorf("unknown display opcode = %v", err) + } +} + +func TestCallback(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + cb := newCallback(c) + // Unknown opcode ignored, callback not done. + if err := cb.handle(99, newDecoder(order, nil)); err != nil { + t.Fatal(err) + } + if cb.done { + t.Fatal("callback should not be done after unknown opcode") + } + // done sets the flag and unregisters. + if err := cb.handle(callbackEvtDone, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(99) }))); err != nil { + t.Fatal(err) + } + if !cb.done || cb.data != 99 { + t.Fatalf("callback done=%v data=%d", cb.done, cb.data) + } + if _, ok := c.handlers[cb.id]; ok { + t.Error("callback should unregister after done") + } + // Truncated done errors. + cb2 := newCallback(c) + if err := cb2.handle(callbackEvtDone, newDecoder(order, nil)); err == nil { + t.Error("truncated done should error") + } +} + +func TestDispatchUnknownObject(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{reads: [][]byte{frame(order, 999, 0, nil)}} + c := NewConn(st, order) + if err := c.Dispatch(); err != nil { + t.Errorf("dispatch to unknown object = %v, want nil", err) + } +} + +func TestDispatchTruncatedHeader(t *testing.T) { + order := binary.LittleEndian + // A 4-byte message: object id present, header word missing. + st := &stubTransport{reads: [][]byte{{1, 0, 0, 0}}} + c := NewConn(st, order) + if err := c.Dispatch(); err == nil { + t.Error("truncated header should error") + } +} + +func TestDispatchReadError(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{readErr: errors.New("boom")} + c := NewConn(st, order) + if err := c.Dispatch(); err == nil { + t.Error("read error should propagate") + } +} + +func TestDispatchHandlerError(t *testing.T) { + order := binary.LittleEndian + // A wl_display.error event dispatched through the full path. + body := bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(2); e.putString("x") }) + st := &stubTransport{reads: [][]byte{frame(order, displayID, displayEvtError, body)}} + c := NewConn(st, order) + if err := c.Dispatch(); err == nil { + t.Error("handler error should propagate through Dispatch") + } +} + +func TestSyncWriteError(t *testing.T) { + st := &stubTransport{writeErr: errors.New("nope")} + c := NewConn(st, binary.LittleEndian) + if _, err := c.display.sync(); err == nil { + t.Error("sync should surface a write error") + } + if err := c.Roundtrip(); err == nil { + t.Error("Roundtrip should surface a sync write error") + } +} + +func TestGetRegistryWriteError(t *testing.T) { + st := &stubTransport{writeErr: errors.New("nope")} + c := NewConn(st, binary.LittleEndian) + if _, err := c.Display().GetRegistry(); err == nil { + t.Error("GetRegistry should surface a write error") + } +} + +func TestBindWriteError(t *testing.T) { + st := &stubTransport{writeErr: errors.New("nope")} + c := NewConn(st, binary.LittleEndian) + reg := &Registry{conn: c, id: 2} + if _, err := reg.bind(1, "wl_shm", 1); err == nil { + t.Error("bind should surface a write error") + } +} diff --git a/internal/wayland/helpers_test.go b/internal/wayland/helpers_test.go new file mode 100644 index 0000000..5382768 --- /dev/null +++ b/internal/wayland/helpers_test.go @@ -0,0 +1,170 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "io" + "net" + "os" + "syscall" + "testing" +) + +// stubTransport is an in-memory transport for deterministic error-branch +// coverage: it replays canned reads/fds and can inject read/write errors, +// with no socket involved. +type stubTransport struct { + reads [][]byte + fds []int + readErr error + writeErr error + writes [][]byte + closeErr error + closed bool +} + +func (s *stubTransport) write(msg []byte, fds []int) error { + if s.writeErr != nil { + return s.writeErr + } + cp := append([]byte(nil), msg...) + s.writes = append(s.writes, cp) + return nil +} + +func (s *stubTransport) read() ([]byte, error) { + if len(s.reads) > 0 { + m := s.reads[0] + s.reads = s.reads[1:] + return m, nil + } + if s.readErr != nil { + return nil, s.readErr + } + return nil, io.EOF +} + +func (s *stubTransport) popFD() (int, bool) { + if len(s.fds) == 0 { + return 0, false + } + fd := s.fds[0] + s.fds = s.fds[1:] + return fd, true +} + +func (s *stubTransport) Close() error { s.closed = true; return s.closeErr } + +// socketPair returns two connected *net.UnixConn endpoints backed by an +// AF_UNIX SOCK_STREAM socketpair. It works on Linux and macOS, so the +// SCM_RIGHTS transport is exercised for real on every developer platform. +func socketPair(t *testing.T) (*net.UnixConn, *net.UnixConn) { + t.Helper() + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + mk := func(fd int) *net.UnixConn { + f := os.NewFile(uintptr(fd), "socketpair") + c, err := net.FileConn(f) + _ = f.Close() + if err != nil { + t.Fatalf("FileConn: %v", err) + } + uc, ok := c.(*net.UnixConn) + if !ok { + t.Fatalf("FileConn returned %T, want *net.UnixConn", c) + } + return uc + } + return mk(fds[0]), mk(fds[1]) +} + +// fakeServer is the compositor side of a connection in tests: it reads +// client requests and sends events using the very same sovereign wire +// codec, so a scripted compositor drives the client end faithfully. +type fakeServer struct { + tr *unixTransport + order ByteOrder +} + +// newFakeServer wraps the compositor end of a socket pair. +func newFakeServer(c *net.UnixConn, order ByteOrder) *fakeServer { + return &fakeServer{tr: newUnixTransport(c, order), order: order} +} + +// readReq reads one client request, returning its object id, opcode and a +// decoder positioned at the argument body. It returns errors (rather than +// calling t.Fatal) so it is safe to use from a compositor goroutine. +func (s *fakeServer) readReq() (uint32, uint16, *decoder, error) { + msg, err := s.tr.read() + if err != nil { + return 0, 0, nil, err + } + d := newDecoder(s.order, msg) + obj := d.getU32() + word := d.getU32() + return obj, uint16(word & 0xffff), d, nil +} + +// sendEvt sends one event to the client, carrying fds out-of-band. +func (s *fakeServer) sendEvt(obj uint32, opcode uint16, body []byte, fds ...int) error { + total := 8 + len(body) + e := newEncoder(s.order) + e.putU32(obj) + e.putU32(uint32(opcode) | uint32(total)<<16) + e.putBytes(body) + return s.tr.write(e.buf, fds) +} + +// popFD drains a descriptor the server received from the client. +func (s *fakeServer) popFD() (int, bool) { return s.tr.popFD() } + +// bodyOf builds an argument body in the given order via fn. +func bodyOf(order ByteOrder, fn func(e *encoder)) []byte { + e := newEncoder(order) + fn(e) + return e.buf +} + +// bothOrders runs fn for the little- and big-endian codec paths so every +// test covers both regardless of the host's native order. +func bothOrders(t *testing.T, fn func(t *testing.T, order ByteOrder)) { + t.Helper() + for _, order := range []ByteOrder{binary.LittleEndian, binary.BigEndian} { + order := order + t.Run(orderName(order), func(t *testing.T) { fn(t, order) }) + } +} + +// decodeWrite parses a captured raw request (header + body) into its object +// id, opcode and a decoder positioned at the body. +func decodeWrite(order ByteOrder, msg []byte) (uint32, uint16, *decoder) { + d := newDecoder(order, msg) + obj := d.getU32() + word := d.getU32() + return obj, uint16(word & 0xffff), d +} + +// lastWrite returns the decoded most-recent captured request on st. +func lastWrite(t *testing.T, st *stubTransport, order ByteOrder) (uint32, uint16, *decoder) { + t.Helper() + if len(st.writes) == 0 { + t.Fatal("no request captured") + } + return decodeWrite(order, st.writes[len(st.writes)-1]) +} + +// newTestConn wires a client Conn to a fake server over a socket pair. +func newTestConn(t *testing.T, order ByteOrder) (*Conn, *fakeServer) { + t.Helper() + cli, srv := socketPair(t) + conn := NewConn(newUnixTransport(cli, order), order) + t.Cleanup(func() { _ = conn.Close() }) + fs := newFakeServer(srv, order) + t.Cleanup(func() { _ = srv.Close() }) + return conn, fs +} diff --git a/internal/wayland/present.go b/internal/wayland/present.go new file mode 100644 index 0000000..f9049fb --- /dev/null +++ b/internal/wayland/present.go @@ -0,0 +1,56 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import "fmt" + +// PackARGB8888 converts a w×h RGBA source (4 bytes per pixel, R,G,B,A byte +// order, srcStride bytes per row) into WL_SHM_FORMAT_ARGB8888 pixels in dst +// (dstStride bytes per row). Each destination pixel is the 32-bit value +// 0xAARRGGBB written in the machine's native byte order — exactly what a +// compositor on the same machine reads back — so the packing is correct on +// little- and big-endian hosts alike. +func PackARGB8888(dst []byte, dstStride int, src []byte, srcStride, w, h int) { + for y := 0; y < h; y++ { + so := y * srcStride + do := y * dstStride + for x := 0; x < w; x++ { + r := uint32(src[so]) + g := uint32(src[so+1]) + b := uint32(src[so+2]) + a := uint32(src[so+3]) + NativeOrder.PutUint32(dst[do:do+4], a<<24|r<<16|g<<8|b) + so += 4 + do += 4 + } + } +} + +// Compositor finds and binds the wl_compositor global. +func (r *Registry) Compositor() (*Compositor, error) { + g, ok := r.Find("wl_compositor") + if !ok { + return nil, fmt.Errorf("wayland: compositor advertises no wl_compositor") + } + return bindCompositor(r, g) +} + +// Shm finds and binds the wl_shm global. +func (r *Registry) Shm() (*Shm, error) { + g, ok := r.Find("wl_shm") + if !ok { + return nil, fmt.Errorf("wayland: compositor advertises no wl_shm") + } + return bindShm(r, g) +} + +// XdgWmBase finds and binds the xdg_wm_base global (stable xdg-shell). +func (r *Registry) XdgWmBase() (*XdgWmBase, error) { + g, ok := r.Find("xdg_wm_base") + if !ok { + return nil, fmt.Errorf("wayland: compositor advertises no xdg_wm_base") + } + return bindXdgWmBase(r, g) +} diff --git a/internal/wayland/present_test.go b/internal/wayland/present_test.go new file mode 100644 index 0000000..3abf60e --- /dev/null +++ b/internal/wayland/present_test.go @@ -0,0 +1,56 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import "testing" + +func TestPackARGB8888(t *testing.T) { + // A 2x2 RGBA image; verify each packed pixel decodes back to ARGB. + const w, h = 2, 2 + src := []byte{ + 10, 20, 30, 255, /* px(0,0) */ 40, 50, 60, 128, // px(1,0) + 70, 80, 90, 200, /* px(0,1) */ 100, 110, 120, 255, // px(1,1) + } + dst := make([]byte, w*h*4) + PackARGB8888(dst, w*4, src, w*4, w, h) + + check := func(px, r, g, b, a uint32) { + v := NativeOrder.Uint32(dst[px*4 : px*4+4]) + if gr := (v >> 16) & 0xff; gr != r { + t.Errorf("px%d R = %d, want %d", px, gr, r) + } + if gg := (v >> 8) & 0xff; gg != g { + t.Errorf("px%d G = %d, want %d", px, gg, g) + } + if gb := v & 0xff; gb != b { + t.Errorf("px%d B = %d, want %d", px, gb, b) + } + if ga := (v >> 24) & 0xff; ga != a { + t.Errorf("px%d A = %d, want %d", px, ga, a) + } + } + check(0, 10, 20, 30, 255) + check(1, 40, 50, 60, 128) + check(2, 70, 80, 90, 200) + check(3, 100, 110, 120, 255) +} + +func TestPackARGB8888Strided(t *testing.T) { + // A source with padding between rows must be read at the given stride + // and written tightly to the destination. + const w, h = 1, 2 + srcStride := 8 // 1 pixel + 4 pad bytes per row + src := make([]byte, srcStride*h) + src[0], src[1], src[2], src[3] = 1, 2, 3, 4 + src[srcStride+0], src[srcStride+1], src[srcStride+2], src[srcStride+3] = 5, 6, 7, 8 + dst := make([]byte, w*4*h) + PackARGB8888(dst, w*4, src, srcStride, w, h) + if v := NativeOrder.Uint32(dst[0:4]); (v>>16)&0xff != 1 { + t.Errorf("row0 R = %d", (v>>16)&0xff) + } + if v := NativeOrder.Uint32(dst[4:8]); (v>>16)&0xff != 5 { + t.Errorf("row1 R = %d", (v>>16)&0xff) + } +} diff --git a/internal/wayland/seat.go b/internal/wayland/seat.go new file mode 100644 index 0000000..1ce3602 --- /dev/null +++ b/internal/wayland/seat.go @@ -0,0 +1,412 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "fmt" + "syscall" +) + +// Seat capability bits (wl_seat.capability). +const ( + SeatCapabilityPointer = 1 + SeatCapabilityKeyboard = 2 + SeatCapabilityTouch = 4 +) + +// Seat is the wl_seat global: a group of input devices (pointer, keyboard, +// touch). It advertises which devices are present and manufactures the +// per-device proxies. +type Seat struct { + conn *Conn + id uint32 + caps uint32 + name string +} + +const seatIfaceVersion = 5 + +// wl_seat request opcodes. +const ( + seatReqGetPointer = 0 + seatReqGetKeyboard = 1 + seatReqRelease = 3 +) + +// wl_seat event opcodes. +const ( + seatEvtCapabilities = 0 + seatEvtName = 1 +) + +// Seat finds and binds the wl_seat global. +func (r *Registry) Seat() (*Seat, error) { + g, ok := r.Find("wl_seat") + if !ok { + return nil, fmt.Errorf("wayland: compositor advertises no wl_seat") + } + ver := min32(g.Version, seatIfaceVersion) + id, err := r.bind(g.Name, "wl_seat", ver) + if err != nil { + return nil, err + } + s := &Seat{conn: r.conn, id: id} + r.conn.register(id, s.handle) + return s, nil +} + +// handle records advertised capabilities and the seat name. +func (s *Seat) handle(opcode uint16, d *decoder) error { + switch opcode { + case seatEvtCapabilities: + caps := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_seat.capabilities") + } + s.caps = caps + return nil + case seatEvtName: + name := d.getString() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_seat.name") + } + s.name = name + return nil + default: + return nil + } +} + +// Capabilities returns the advertised capability bitmask. +func (s *Seat) Capabilities() uint32 { return s.caps } + +// Name returns the seat's human-readable name. +func (s *Seat) Name() string { return s.name } + +// HasPointer reports whether the seat has a pointer device. +func (s *Seat) HasPointer() bool { return s.caps&SeatCapabilityPointer != 0 } + +// HasKeyboard reports whether the seat has a keyboard device. +func (s *Seat) HasKeyboard() bool { return s.caps&SeatCapabilityKeyboard != 0 } + +// GetPointer obtains the seat's pointer device. +func (s *Seat) GetPointer() (*Pointer, error) { + id := s.conn.allocID() + e := newEncoder(s.conn.order) + e.putU32(id) + if err := s.conn.send(s.id, seatReqGetPointer, e.buf, nil); err != nil { + return nil, err + } + p := &Pointer{conn: s.conn, id: id} + s.conn.register(id, p.handle) + return p, nil +} + +// GetKeyboard obtains the seat's keyboard device. +func (s *Seat) GetKeyboard() (*Keyboard, error) { + id := s.conn.allocID() + e := newEncoder(s.conn.order) + e.putU32(id) + if err := s.conn.send(s.id, seatReqGetKeyboard, e.buf, nil); err != nil { + return nil, err + } + k := &Keyboard{conn: s.conn, id: id, keymap: &Keymap{codeSyms: map[uint32][]string{}}} + s.conn.register(id, k.handle) + return k, nil +} + +// Release releases the seat object. +func (s *Seat) Release() error { + err := s.conn.send(s.id, seatReqRelease, nil, nil) + s.conn.unregister(s.id) + return err +} + +// --- wl_pointer ----------------------------------------------------------- + +// Linux input-event-codes button numbers reported by wl_pointer.button. +const ( + BtnLeft = 0x110 + BtnRight = 0x111 + BtnMiddle = 0x112 +) + +// wl_pointer.button / wl_keyboard.key state values. +const ( + StateReleased = 0 + StatePressed = 1 +) + +// wl_pointer.axis values. +const ( + AxisVerticalScroll = 0 + AxisHorizontalScroll = 1 +) + +// Pointer is a wl_pointer device. It decodes enter/leave/motion/button/axis +// events and delivers them through the callback fields the window layer sets. +type Pointer struct { + conn *Conn + id uint32 + + OnEnter func(x, y Fixed) + OnLeave func() + OnMotion func(x, y Fixed) + OnButton func(button uint32, pressed bool) + OnAxis func(axis uint32, value Fixed) +} + +// wl_pointer event opcodes. +const ( + pointerEvtEnter = 0 + pointerEvtLeave = 1 + pointerEvtMotion = 2 + pointerEvtButton = 3 + pointerEvtAxis = 4 +) + +// wl_pointer request opcode. +const pointerReqRelease = 1 + +// handle decodes a pointer event and invokes the matching callback. +func (p *Pointer) handle(opcode uint16, d *decoder) error { + switch opcode { + case pointerEvtEnter: + d.getU32() // serial + d.getU32() // surface + x := d.getFixed() + y := d.getFixed() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_pointer.enter") + } + if p.OnEnter != nil { + p.OnEnter(x, y) + } + case pointerEvtLeave: + d.getU32() // serial + d.getU32() // surface + if !d.ok { + return fmt.Errorf("wayland: truncated wl_pointer.leave") + } + if p.OnLeave != nil { + p.OnLeave() + } + case pointerEvtMotion: + d.getU32() // time + x := d.getFixed() + y := d.getFixed() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_pointer.motion") + } + if p.OnMotion != nil { + p.OnMotion(x, y) + } + case pointerEvtButton: + d.getU32() // serial + d.getU32() // time + button := d.getU32() + state := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_pointer.button") + } + if p.OnButton != nil { + p.OnButton(button, state == StatePressed) + } + case pointerEvtAxis: + d.getU32() // time + axis := d.getU32() + value := d.getFixed() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_pointer.axis") + } + if p.OnAxis != nil { + p.OnAxis(axis, value) + } + } + return nil +} + +// Release releases the pointer object. +func (p *Pointer) Release() error { + err := p.conn.send(p.id, pointerReqRelease, nil, nil) + p.conn.unregister(p.id) + return err +} + +// --- wl_keyboard ---------------------------------------------------------- + +// wl_keyboard.keymap format values. +const ( + KeymapFormatNoKeymap = 0 + KeymapFormatXkbV1 = 1 +) + +// Core X11-compatible modifier mask bits used by standard keymaps; the +// wl_keyboard.modifiers masks follow this convention for the real modifiers. +const ( + modMaskShift = 1 << 0 + modMaskControl = 1 << 2 + modMaskAlt = 1 << 3 +) + +// Keyboard is a wl_keyboard device. It ingests the xkb keymap, tracks +// modifier state and delivers key press/release through OnKey. +type Keyboard struct { + conn *Conn + id uint32 + keymap *Keymap + mods uint32 + + repeatRate int + repeatDelay int + + OnKey func(evdevCode uint32, pressed bool) + OnModifiers func() + OnEnter func() + OnLeave func() +} + +// wl_keyboard event opcodes. +const ( + keyboardEvtKeymap = 0 + keyboardEvtEnter = 1 + keyboardEvtLeave = 2 + keyboardEvtKey = 3 + keyboardEvtModifiers = 4 + keyboardEvtRepeatInfo = 5 +) + +// wl_keyboard request opcode. +const keyboardReqRelease = 0 + +// mapReadOnly and unmapReadOnly wrap the keymap-fd mmap syscalls behind +// package variables so a test can exercise the failure path. +var ( + mapReadOnly = func(fd, size int) ([]byte, error) { + return syscall.Mmap(fd, 0, size, syscall.PROT_READ, syscall.MAP_PRIVATE) + } + unmapReadOnly = syscall.Munmap +) + +// handle decodes a keyboard event. +func (k *Keyboard) handle(opcode uint16, d *decoder) error { + switch opcode { + case keyboardEvtKeymap: + return k.handleKeymap(d) + case keyboardEvtEnter: + d.getU32() // serial + d.getU32() // surface + d.getArray() // pressed keys + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.enter") + } + if k.OnEnter != nil { + k.OnEnter() + } + case keyboardEvtLeave: + d.getU32() // serial + d.getU32() // surface + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.leave") + } + if k.OnLeave != nil { + k.OnLeave() + } + case keyboardEvtKey: + d.getU32() // serial + d.getU32() // time + key := d.getU32() + state := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.key") + } + if k.OnKey != nil { + k.OnKey(key, state == StatePressed) + } + case keyboardEvtModifiers: + d.getU32() // serial + dep := d.getU32() + lat := d.getU32() + loc := d.getU32() + d.getU32() // group + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.modifiers") + } + k.mods = dep | lat | loc + if k.OnModifiers != nil { + k.OnModifiers() + } + case keyboardEvtRepeatInfo: + rate := d.getI32() + delay := d.getI32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.repeat_info") + } + k.repeatRate = int(rate) + k.repeatDelay = int(delay) + } + return nil +} + +// handleKeymap mmaps the keymap fd, parses it and releases the mapping and +// descriptor. A non-xkb_v1 format leaves the (empty) keymap in place. +func (k *Keyboard) handleKeymap(d *decoder) error { + format := d.getU32() + size := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_keyboard.keymap") + } + fd, ok := k.conn.recvFD() + if !ok { + return fmt.Errorf("wayland: wl_keyboard.keymap missing fd") + } + defer syscall.Close(fd) + if format != KeymapFormatXkbV1 { + return nil + } + data, err := mapReadOnly(fd, int(size)) + if err != nil { + return fmt.Errorf("wayland: keymap mmap: %w", err) + } + defer unmapReadOnly(data) + k.keymap = ParseKeymap(cstr(data)) + return nil +} + +// cstr returns the NUL-terminated prefix of b as a string (the keymap text +// includes a trailing NUL within the reported size). +func cstr(b []byte) string { + for i, c := range b { + if c == 0 { + return string(b[:i]) + } + } + return string(b) +} + +// Keymap returns the parsed keymap. +func (k *Keyboard) Keymap() *Keymap { return k.keymap } + +// Shift reports whether Shift is currently held. +func (k *Keyboard) Shift() bool { return k.mods&modMaskShift != 0 } + +// Ctrl reports whether Control is currently held. +func (k *Keyboard) Ctrl() bool { return k.mods&modMaskControl != 0 } + +// Alt reports whether Alt is currently held. +func (k *Keyboard) Alt() bool { return k.mods&modMaskAlt != 0 } + +// RepeatRate returns the key-repeat rate in keys per second (0 disables). +func (k *Keyboard) RepeatRate() int { return k.repeatRate } + +// RepeatDelay returns the key-repeat delay in milliseconds. +func (k *Keyboard) RepeatDelay() int { return k.repeatDelay } + +// Release releases the keyboard object. +func (k *Keyboard) Release() error { + err := k.conn.send(k.id, keyboardReqRelease, nil, nil) + k.conn.unregister(k.id) + return err +} diff --git a/internal/wayland/seat_test.go b/internal/wayland/seat_test.go new file mode 100644 index 0000000..7db5883 --- /dev/null +++ b/internal/wayland/seat_test.go @@ -0,0 +1,343 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "errors" + "os" + "testing" +) + +func TestSeatBind(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + reg := &Registry{conn: c} + // No global -> error. + if _, err := reg.Seat(); err == nil { + t.Error("Seat with no global should error") + } + reg.globals = []Global{{Name: 1, Interface: "wl_seat", Version: 5}} + seat, err := reg.Seat() + if err != nil { + t.Fatalf("Seat: %v", err) + } + if _, ok := c.handlers[seat.id]; !ok { + t.Error("bound seat should register a handler") + } +} + +func TestSeatBindWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_seat", Version: 5}} + if _, err := reg.Seat(); err == nil { + t.Error("seat bind write error should propagate") + } +} + +func TestSeatHandle(t *testing.T) { + order := binary.LittleEndian + s := &Seat{} + caps := bodyOf(order, func(e *encoder) { e.putU32(SeatCapabilityPointer | SeatCapabilityKeyboard) }) + if err := s.handle(seatEvtCapabilities, newDecoder(order, caps)); err != nil { + t.Fatal(err) + } + if !s.HasPointer() || !s.HasKeyboard() { + t.Errorf("caps = %#x", s.Capabilities()) + } + if s.Capabilities()&SeatCapabilityTouch != 0 { + t.Error("touch should be absent") + } + name := bodyOf(order, func(e *encoder) { e.putString("seat0") }) + if err := s.handle(seatEvtName, newDecoder(order, name)); err != nil { + t.Fatal(err) + } + if s.Name() != "seat0" { + t.Errorf("name = %q", s.Name()) + } + // unknown opcode ignored; truncated caps/name error. + if err := s.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown seat opcode = %v", err) + } + if err := s.handle(seatEvtCapabilities, newDecoder(order, nil)); err == nil { + t.Error("truncated capabilities should error") + } + if err := s.handle(seatEvtName, newDecoder(order, nil)); err == nil { + t.Error("truncated name should error") + } +} + +func TestSeatGetDevicesAndRelease(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + s := &Seat{conn: c, id: 7} + p, err := s.GetPointer() + if err != nil { + t.Fatalf("GetPointer: %v", err) + } + if _, ok := c.handlers[p.id]; !ok { + t.Error("pointer should be registered") + } + k, err := s.GetKeyboard() + if err != nil { + t.Fatalf("GetKeyboard: %v", err) + } + if k.Keymap() == nil { + t.Error("keyboard should start with an empty keymap") + } + if err := s.Release(); err != nil { + t.Fatalf("Release: %v", err) + } + if _, ok := c.handlers[s.id]; ok { + t.Error("Release should unregister the seat") + } +} + +func TestSeatDeviceWriteErrors(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + s := &Seat{conn: c, id: 7} + if _, err := s.GetPointer(); err == nil { + t.Error("GetPointer write error") + } + if _, err := s.GetKeyboard(); err == nil { + t.Error("GetKeyboard write error") + } + if err := s.Release(); err == nil { + t.Error("Release write error") + } +} + +func TestPointerHandle(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + p := &Pointer{conn: c, id: 9} + + var enterX, enterY int + var left bool + var motX, motY int + var btn uint32 + var pressed bool + var axis uint32 + var axisVal int + p.OnEnter = func(x, y Fixed) { enterX, enterY = x.Int(), y.Int() } + p.OnLeave = func() { left = true } + p.OnMotion = func(x, y Fixed) { motX, motY = x.Int(), y.Int() } + p.OnButton = func(b uint32, pr bool) { btn, pressed = b, pr } + p.OnAxis = func(a uint32, v Fixed) { axis, axisVal = a, v.Int() } + + must := func(op uint16, body []byte) { + if err := p.handle(op, newDecoder(order, body)); err != nil { + t.Fatalf("op %d: %v", op, err) + } + } + must(pointerEvtEnter, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(2); e.putFixed(FixedFromInt(30)); e.putFixed(FixedFromInt(40)) })) + must(pointerEvtLeave, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(2) })) + must(pointerEvtMotion, bodyOf(order, func(e *encoder) { e.putU32(0); e.putFixed(FixedFromInt(11)); e.putFixed(FixedFromInt(12)) })) + must(pointerEvtButton, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(0); e.putU32(BtnLeft); e.putU32(StatePressed) })) + must(pointerEvtAxis, bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(AxisVerticalScroll); e.putFixed(FixedFromInt(5)) })) + if enterX != 30 || enterY != 40 || !left || motX != 11 || motY != 12 || btn != BtnLeft || !pressed || axis != AxisVerticalScroll || axisVal != 5 { + t.Fatalf("pointer callbacks not all fired: enter(%d,%d) left=%v mot(%d,%d) btn=%d pressed=%v axis=%d val=%d", + enterX, enterY, left, motX, motY, btn, pressed, axis, axisVal) + } + // button release path. + must(pointerEvtButton, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(0); e.putU32(BtnLeft); e.putU32(StateReleased) })) + if pressed { + t.Error("release should report pressed=false") + } + // unknown opcode ignored. + if err := p.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown pointer opcode = %v", err) + } + if err := p.Release(); err != nil { + t.Fatalf("Release: %v", err) + } +} + +func TestPointerHandleNilCallbacksAndTruncation(t *testing.T) { + order := binary.LittleEndian + p := &Pointer{} // no callbacks set + full := map[uint16][]byte{ + pointerEvtEnter: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putFixed(0); e.putFixed(0) }), + pointerEvtLeave: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0) }), + pointerEvtMotion: bodyOf(order, func(e *encoder) { e.putU32(0); e.putFixed(0); e.putFixed(0) }), + pointerEvtButton: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putU32(0); e.putU32(0) }), + pointerEvtAxis: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putFixed(0) }), + } + for op, body := range full { + if err := p.handle(op, newDecoder(order, body)); err != nil { + t.Errorf("nil-callback op %d: %v", op, err) + } + if err := p.handle(op, newDecoder(order, nil)); err == nil { + t.Errorf("truncated op %d should error", op) + } + } +} + +// makeKeymapFD writes text (NUL-terminated) to a temp file and returns its +// descriptor and size for a wl_keyboard.keymap event. +func makeKeymapFD(t *testing.T, text string) (int, int) { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "km") + if err != nil { + t.Fatalf("temp: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + if _, err := f.WriteString(text + "\x00"); err != nil { + t.Fatalf("write: %v", err) + } + return int(f.Fd()), len(text) + 1 +} + +func TestKeyboardKeymap(t *testing.T) { + order := binary.LittleEndian + fd, size := makeKeymapFD(t, kmText) + st := &stubTransport{fds: []int{fd}} + c := NewConn(st, order) + k := &Keyboard{conn: c, keymap: &Keymap{codeSyms: map[uint32][]string{}}} + body := bodyOf(order, func(e *encoder) { e.putU32(KeymapFormatXkbV1); e.putU32(uint32(size)) }) + if err := k.handle(keyboardEvtKeymap, newDecoder(order, body)); err != nil { + t.Fatalf("keymap: %v", err) + } + if key := k.Keymap().Lookup(30, false); !key.HasRune || key.Rune != 'a' { + t.Errorf("parsed keymap Lookup(30) = %+v", key) + } +} + +func TestKeyboardKeymapMissingFD(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) // no fds queued + k := &Keyboard{conn: c} + body := bodyOf(order, func(e *encoder) { e.putU32(KeymapFormatXkbV1); e.putU32(10) }) + if err := k.handle(keyboardEvtKeymap, newDecoder(order, body)); err == nil { + t.Error("keymap with no fd should error") + } +} + +func TestKeyboardKeymapNonXkb(t *testing.T) { + order := binary.LittleEndian + fd, _ := makeKeymapFD(t, "irrelevant") + st := &stubTransport{fds: []int{fd}} + c := NewConn(st, order) + empty := &Keymap{codeSyms: map[uint32][]string{}} + k := &Keyboard{conn: c, keymap: empty} + body := bodyOf(order, func(e *encoder) { e.putU32(KeymapFormatNoKeymap); e.putU32(5) }) + if err := k.handle(keyboardEvtKeymap, newDecoder(order, body)); err != nil { + t.Fatalf("non-xkb keymap: %v", err) + } + if k.Keymap() != empty { + t.Error("non-xkb keymap should leave the keymap unchanged") + } +} + +func TestKeyboardKeymapMmapError(t *testing.T) { + order := binary.LittleEndian + fd, size := makeKeymapFD(t, kmText) + st := &stubTransport{fds: []int{fd}} + c := NewConn(st, order) + k := &Keyboard{conn: c} + orig := mapReadOnly + mapReadOnly = func(int, int) ([]byte, error) { return nil, errors.New("mmap boom") } + defer func() { mapReadOnly = orig }() + body := bodyOf(order, func(e *encoder) { e.putU32(KeymapFormatXkbV1); e.putU32(uint32(size)) }) + if err := k.handle(keyboardEvtKeymap, newDecoder(order, body)); err == nil { + t.Error("keymap mmap error should propagate") + } +} + +func TestKeyboardKeymapTruncated(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + k := &Keyboard{conn: c} + if err := k.handle(keyboardEvtKeymap, newDecoder(order, nil)); err == nil { + t.Error("truncated keymap should error") + } +} + +func TestKeyboardEvents(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + k := &Keyboard{conn: c, id: 10, keymap: &Keymap{codeSyms: map[uint32][]string{}}} + + var entered, leftFocus, modsSeen bool + var keyCode uint32 + var keyPressed bool + k.OnEnter = func() { entered = true } + k.OnLeave = func() { leftFocus = true } + k.OnKey = func(code uint32, pressed bool) { keyCode, keyPressed = code, pressed } + k.OnModifiers = func() { modsSeen = true } + + must := func(op uint16, body []byte) { + if err := k.handle(op, newDecoder(order, body)); err != nil { + t.Fatalf("op %d: %v", op, err) + } + } + must(keyboardEvtEnter, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(2); e.putArray([]byte{30, 0, 0, 0}) })) + must(keyboardEvtLeave, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(2) })) + must(keyboardEvtKey, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(0); e.putU32(30); e.putU32(StatePressed) })) + must(keyboardEvtModifiers, bodyOf(order, func(e *encoder) { e.putU32(1); e.putU32(modMaskShift | modMaskControl); e.putU32(0); e.putU32(0); e.putU32(0) })) + must(keyboardEvtRepeatInfo, bodyOf(order, func(e *encoder) { e.putI32(25); e.putI32(600) })) + + if !entered || !leftFocus || keyCode != 30 || !keyPressed || !modsSeen { + t.Fatalf("keyboard callbacks: entered=%v left=%v key=%d pressed=%v mods=%v", entered, leftFocus, keyCode, keyPressed, modsSeen) + } + if !k.Shift() || !k.Ctrl() || k.Alt() { + t.Errorf("modifiers shift=%v ctrl=%v alt=%v", k.Shift(), k.Ctrl(), k.Alt()) + } + if k.RepeatRate() != 25 || k.RepeatDelay() != 600 { + t.Errorf("repeat = %d/%d", k.RepeatRate(), k.RepeatDelay()) + } + // unknown opcode ignored. + if err := k.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown keyboard opcode = %v", err) + } + if err := k.Release(); err != nil { + t.Fatalf("Release: %v", err) + } +} + +func TestKeyboardEventsNilCallbacksAndTruncation(t *testing.T) { + order := binary.LittleEndian + c := NewConn(&stubTransport{}, order) + k := &Keyboard{conn: c, keymap: &Keymap{codeSyms: map[uint32][]string{}}} // no callbacks + full := map[uint16][]byte{ + keyboardEvtEnter: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putArray(nil) }), + keyboardEvtLeave: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0) }), + keyboardEvtKey: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putU32(0); e.putU32(0) }), + keyboardEvtModifiers: bodyOf(order, func(e *encoder) { e.putU32(0); e.putU32(0); e.putU32(0); e.putU32(0); e.putU32(0) }), + keyboardEvtRepeatInfo: bodyOf(order, func(e *encoder) { e.putI32(0); e.putI32(0) }), + } + for op, body := range full { + if err := k.handle(op, newDecoder(order, body)); err != nil { + t.Errorf("nil-callback op %d: %v", op, err) + } + if err := k.handle(op, newDecoder(order, nil)); err == nil { + t.Errorf("truncated op %d should error", op) + } + } +} + +func TestKeyboardReleaseWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + k := &Keyboard{conn: c, id: 10} + if err := k.Release(); err == nil { + t.Error("keyboard Release write error should propagate") + } + p := &Pointer{conn: c, id: 9} + if err := p.Release(); err == nil { + t.Error("pointer Release write error should propagate") + } +} + +func TestCstr(t *testing.T) { + if got := cstr([]byte("hello\x00world")); got != "hello" { + t.Errorf("cstr = %q", got) + } + if got := cstr([]byte("nonul")); got != "nonul" { + t.Errorf("cstr no NUL = %q", got) + } +} diff --git a/internal/wayland/shm.go b/internal/wayland/shm.go new file mode 100644 index 0000000..f151c03 --- /dev/null +++ b/internal/wayland/shm.go @@ -0,0 +1,282 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "fmt" + "os" + "path/filepath" + "sync/atomic" + "syscall" +) + +// wl_shm pixel formats (a subset of the DRM fourcc set). ARGB8888 stores a +// 32-bit value 0xAARRGGBB per pixel; on the wire the region is filled with +// that value in the machine's native byte order, which the compositor — +// running on the same machine — reads back identically. +const ( + ShmFormatARGB8888 = 0 + ShmFormatXRGB8888 = 1 +) + +// anonCounter uniquifies the backing-file name within a process. +var anonCounter uint64 + +// createAnonFile creates an unlinked, zero-length-then-truncated file of +// size bytes in $XDG_RUNTIME_DIR (a tmpfs) and returns its descriptor. The +// file is unlinked immediately, so it lives only as long as the descriptor; +// the descriptor is what gets passed to the compositor over SCM_RIGHTS. +// +// This is the portable equivalent of memfd_create: it needs only +// syscall.Open/Unlink/Ftruncate, which exist on every target (and on macOS, +// so the shm math is unit-testable on the CI macOS lane too). +func createAnonFile(size int) (int, error) { + if size <= 0 { + return -1, fmt.Errorf("wayland: shm size %d must be positive", size) + } + dir := os.Getenv("XDG_RUNTIME_DIR") + if dir == "" { + dir = os.TempDir() + } + n := atomic.AddUint64(&anonCounter, 1) + name := filepath.Join(dir, fmt.Sprintf("gw-wl-shm-%d-%d", os.Getpid(), n)) + fd, err := syscall.Open(name, syscall.O_RDWR|syscall.O_CREAT|syscall.O_EXCL|syscall.O_CLOEXEC, 0o600) + if err != nil { + return -1, fmt.Errorf("wayland: shm open: %w", err) + } + _ = syscall.Unlink(name) + if err := ftruncateFD(fd, int64(size)); err != nil { + _ = syscall.Close(fd) + return -1, fmt.Errorf("wayland: shm ftruncate: %w", err) + } + return fd, nil +} + +// ftruncateFD is a package variable wrapping syscall.Ftruncate so a test +// can force its (rare) failure and reach full branch coverage. +var ftruncateFD = syscall.Ftruncate + +// shmRegion is an mmap'd anonymous shared-memory region: the descriptor is +// handed to the compositor, and data is the client-writable pixel store the +// compositor reads from. +type shmRegion struct { + fd int + data []byte + size int +} + +// newShmRegion allocates and maps a shared-memory region of size bytes. +func newShmRegion(size int) (*shmRegion, error) { + fd, err := createAnonFile(size) + if err != nil { + return nil, err + } + data, err := mmapRegion(fd, size) + if err != nil { + _ = syscall.Close(fd) + return nil, err + } + return &shmRegion{fd: fd, data: data, size: size}, nil +} + +// mmapRegion, munmapRegion and closeFD wrap the syscalls behind package +// variables so tests can force the (kernel-rare) failure paths and reach +// full branch coverage without a real fault. +var ( + mmapRegion = func(fd, size int) ([]byte, error) { + data, err := syscall.Mmap(fd, 0, size, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED) + if err != nil { + return nil, fmt.Errorf("wayland: shm mmap: %w", err) + } + return data, nil + } + munmapRegion = syscall.Munmap + closeFD = syscall.Close +) + +// Close unmaps the region and closes its descriptor, returning the first +// error encountered (both steps are attempted regardless). +func (r *shmRegion) Close() error { + var first error + if r.data != nil { + if err := munmapRegion(r.data); err != nil && first == nil { + first = err + } + r.data = nil + } + if r.fd >= 0 { + if err := closeFD(r.fd); err != nil && first == nil { + first = err + } + r.fd = -1 + } + return first +} + +// --- wl_shm / wl_shm_pool / wl_buffer ------------------------------------- + +// Shm is the wl_shm global: it advertises supported pixel formats and +// creates shared-memory pools. +type Shm struct { + conn *Conn + id uint32 + formats []uint32 +} + +const shmIfaceVersion = 1 + +// wl_shm request opcode. +const shmReqCreatePool = 0 + +// wl_shm event opcode. +const shmEvtFormat = 0 + +// bindShm binds the wl_shm global from the registry. +func bindShm(reg *Registry, g Global) (*Shm, error) { + ver := min32(g.Version, shmIfaceVersion) + id, err := reg.bind(g.Name, "wl_shm", ver) + if err != nil { + return nil, err + } + s := &Shm{conn: reg.conn, id: id} + reg.conn.register(id, s.handle) + return s, nil +} + +// handle records advertised formats. +func (s *Shm) handle(opcode uint16, d *decoder) error { + if opcode != shmEvtFormat { + return nil + } + f := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated wl_shm.format") + } + s.formats = append(s.formats, f) + return nil +} + +// Supports reports whether the compositor advertised the given format. +func (s *Shm) Supports(format uint32) bool { + for _, f := range s.formats { + if f == format { + return true + } + } + return false +} + +// CreatePool allocates a shared-memory region of size bytes and creates a +// wl_shm_pool over it, passing the descriptor to the compositor. +func (s *Shm) CreatePool(size int) (*ShmPool, error) { + region, err := newShmRegion(size) + if err != nil { + return nil, err + } + poolID := s.conn.allocID() + e := newEncoder(s.conn.order) + e.putU32(poolID) // new_id pool + e.putI32(int32(size)) // size + if err := s.conn.send(s.id, shmReqCreatePool, e.buf, []int{region.fd}); err != nil { + _ = region.Close() + return nil, err + } + p := &ShmPool{conn: s.conn, id: poolID, region: region} + s.conn.register(poolID, p.handle) + return p, nil +} + +// ShmPool is a wl_shm_pool: a mapped memory region from which buffers are +// carved. +type ShmPool struct { + conn *Conn + id uint32 + region *shmRegion +} + +// wl_shm_pool request opcodes. +const ( + shmPoolReqCreateBuffer = 0 + shmPoolReqDestroy = 1 +) + +// handle: wl_shm_pool has no events; the method exists so the object has a +// registered dispatcher (an unexpected event is ignored). +func (p *ShmPool) handle(uint16, *decoder) error { return nil } + +// Data returns the writable pixel store backing the pool. +func (p *ShmPool) Data() []byte { return p.region.data } + +// CreateBuffer carves a wl_buffer from the pool at byte offset with the +// given geometry and pixel format. +func (p *ShmPool) CreateBuffer(offset, width, height, stride int, format uint32) (*Buffer, error) { + bufID := p.conn.allocID() + e := newEncoder(p.conn.order) + e.putU32(bufID) + e.putI32(int32(offset)) + e.putI32(int32(width)) + e.putI32(int32(height)) + e.putI32(int32(stride)) + e.putU32(format) + if err := p.conn.send(p.id, shmPoolReqCreateBuffer, e.buf, nil); err != nil { + return nil, err + } + b := &Buffer{conn: p.conn, id: bufID, offset: offset, released: true} + p.conn.register(bufID, b.handle) + return b, nil +} + +// Destroy releases the pool object and its backing region. The already +// created buffers remain valid until they too are destroyed. +func (p *ShmPool) Destroy() error { + err := p.conn.send(p.id, shmPoolReqDestroy, nil, nil) + p.conn.unregister(p.id) + if cerr := p.region.Close(); err == nil { + err = cerr + } + return err +} + +// Buffer is a wl_buffer: a rectangular view into a pool the compositor can +// read while it is attached to a surface. released tracks whether the +// compositor currently holds it (false) or has handed it back (true). +type Buffer struct { + conn *Conn + id uint32 + offset int + released bool +} + +// wl_buffer request opcode. +const bufferReqDestroy = 0 + +// wl_buffer event opcode. +const bufferEvtRelease = 0 + +// handle marks the buffer released when the compositor is done reading it. +func (b *Buffer) handle(opcode uint16, _ *decoder) error { + if opcode == bufferEvtRelease { + b.released = true + } + return nil +} + +// Released reports whether the buffer is free for the client to redraw. +func (b *Buffer) Released() bool { return b.released } + +// Destroy releases the buffer object. +func (b *Buffer) Destroy() error { + err := b.conn.send(b.id, bufferReqDestroy, nil, nil) + b.conn.unregister(b.id) + return err +} + +// min32 returns the smaller of two uint32 values. +func min32(a, b uint32) uint32 { + if a < b { + return a + } + return b +} diff --git a/internal/wayland/shm_test.go b/internal/wayland/shm_test.go new file mode 100644 index 0000000..db09120 --- /dev/null +++ b/internal/wayland/shm_test.go @@ -0,0 +1,297 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "errors" + "syscall" + "testing" +) + +func TestCreateAnonFile(t *testing.T) { + // With XDG_RUNTIME_DIR unset, the backing file lands in os.TempDir() + // (this branch is taken on macOS but not Linux, so force it here for a + // deterministic 100% regardless of the host environment). + t.Setenv("XDG_RUNTIME_DIR", "") + fd, err := createAnonFile(4096) + if err != nil { + t.Fatalf("createAnonFile (TempDir fallback): %v", err) + } + _ = syscall.Close(fd) + + // With XDG_RUNTIME_DIR set to a real directory, the file lands there. + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + fd, err = createAnonFile(4096) + if err != nil { + t.Fatalf("createAnonFile: %v", err) + } + if fd < 0 { + t.Fatalf("bad fd %d", fd) + } + region := &shmRegion{fd: fd, size: 4096} + data, err := mmapRegion(fd, 4096) + if err != nil { + t.Fatalf("mmap: %v", err) + } + region.data = data + region.data[0] = 0xAB + if region.data[0] != 0xAB { + t.Error("shm region not writable") + } + if err := region.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + // Second Close is a no-op. + if err := region.Close(); err != nil { + t.Fatalf("double Close: %v", err) + } +} + +func TestShmRegionCloseErrors(t *testing.T) { + origM, origC := munmapRegion, closeFD + defer func() { munmapRegion, closeFD = origM, origC }() + // Munmap fails: its error is returned and close still runs. + munmapRegion = func([]byte) error { return errors.New("munmap boom") } + closeFD = func(int) error { return nil } + r := &shmRegion{fd: 3, data: []byte{0}, size: 1} + if err := r.Close(); err == nil { + t.Error("munmap error should be returned") + } + // Close fails while munmap succeeds. + munmapRegion = func([]byte) error { return nil } + closeFD = func(int) error { return errors.New("close boom") } + r2 := &shmRegion{fd: 3, data: []byte{0}, size: 1} + if err := r2.Close(); err == nil { + t.Error("close error should be returned") + } +} + +func TestCreateAnonFileBadSize(t *testing.T) { + if _, err := createAnonFile(0); err == nil { + t.Error("size 0 should error") + } + if _, err := createAnonFile(-1); err == nil { + t.Error("negative size should error") + } +} + +func TestCreateAnonFileBadDir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/no/such/dir/for/wayland/shm") + if _, err := createAnonFile(4096); err == nil { + t.Error("open in nonexistent dir should error") + } +} + +func TestCreateAnonFileFtruncateError(t *testing.T) { + orig := ftruncateFD + ftruncateFD = func(int, int64) error { return errors.New("ftruncate boom") } + defer func() { ftruncateFD = orig }() + if _, err := createAnonFile(4096); err == nil { + t.Error("ftruncate failure should propagate") + } +} + +func TestShmBindSuccess(t *testing.T) { + c := NewConn(&stubTransport{}, binary.LittleEndian) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_shm", Version: 1}} + s, err := reg.Shm() + if err != nil { + t.Fatalf("Shm: %v", err) + } + if _, ok := c.handlers[s.id]; !ok { + t.Error("bound wl_shm should register a handler") + } +} + +func TestNewShmRegionMmapError(t *testing.T) { + orig := mmapRegion + mmapRegion = func(int, int) ([]byte, error) { return nil, errors.New("mmap boom") } + defer func() { mmapRegion = orig }() + if _, err := newShmRegion(4096); err == nil { + t.Error("mmap failure should propagate") + } +} + +func TestNewShmRegionBadSize(t *testing.T) { + if _, err := newShmRegion(0); err == nil { + t.Error("size 0 should error before mmap") + } +} + +func TestShmHandle(t *testing.T) { + order := binary.LittleEndian + s := &Shm{} + if err := s.handle(shmEvtFormat, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(ShmFormatARGB8888) }))); err != nil { + t.Fatal(err) + } + if err := s.handle(shmEvtFormat, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(ShmFormatXRGB8888) }))); err != nil { + t.Fatal(err) + } + if !s.Supports(ShmFormatARGB8888) || !s.Supports(ShmFormatXRGB8888) { + t.Error("advertised formats should be supported") + } + if s.Supports(0x99) { + t.Error("unadvertised format should not be supported") + } + // Unknown opcode ignored. + if err := s.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown shm opcode = %v", err) + } + // Truncated format errors. + if err := s.handle(shmEvtFormat, newDecoder(order, nil)); err == nil { + t.Error("truncated format should error") + } +} + +func TestShmBindNoGlobal(t *testing.T) { + c := NewConn(&stubTransport{}, binary.LittleEndian) + reg := &Registry{conn: c} + if _, err := reg.Shm(); err == nil { + t.Error("Shm with no global should error") + } + if _, err := reg.Compositor(); err == nil { + t.Error("Compositor with no global should error") + } + if _, err := reg.XdgWmBase(); err == nil { + t.Error("XdgWmBase with no global should error") + } +} + +func TestShmBindWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_shm", Version: 1}} + if _, err := reg.Shm(); err == nil { + t.Error("bind write error should propagate") + } +} + +func TestCreatePool(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + s := &Shm{conn: c, id: 3} + pool, err := s.CreatePool(8192) + if err != nil { + t.Fatalf("CreatePool: %v", err) + } + if len(pool.Data()) != 8192 { + t.Fatalf("pool data len = %d", len(pool.Data())) + } + obj, op, d := lastWrite(t, st, order) + if obj != 3 || op != shmReqCreatePool { + t.Fatalf("create_pool obj=%d op=%d", obj, op) + } + if id := d.getU32(); id != pool.id { + t.Errorf("pool new_id = %d, want %d", id, pool.id) + } + if sz := d.getI32(); sz != 8192 { + t.Errorf("pool size = %d", sz) + } + if err := pool.handle(0, nil); err != nil { + t.Errorf("pool.handle = %v", err) + } + if err := pool.Destroy(); err != nil { + t.Fatalf("pool Destroy: %v", err) + } +} + +func TestCreatePoolRegionError(t *testing.T) { + c := NewConn(&stubTransport{}, binary.LittleEndian) + s := &Shm{conn: c, id: 3} + if _, err := s.CreatePool(0); err == nil { + t.Error("CreatePool with bad size should error") + } +} + +func TestCreatePoolSendError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + s := &Shm{conn: c, id: 3} + if _, err := s.CreatePool(4096); err == nil { + t.Error("CreatePool send error should propagate (and free the region)") + } +} + +func TestCreateBuffer(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + s := &Shm{conn: c, id: 3} + pool, err := s.CreatePool(4096) + if err != nil { + t.Fatalf("CreatePool: %v", err) + } + buf, err := pool.CreateBuffer(0, 16, 16, 64, ShmFormatARGB8888) + if err != nil { + t.Fatalf("CreateBuffer: %v", err) + } + if !buf.Released() { + t.Error("fresh buffer should be released (client-owned)") + } + obj, op, d := lastWrite(t, st, order) + if obj != pool.id || op != shmPoolReqCreateBuffer { + t.Fatalf("create_buffer obj=%d op=%d", obj, op) + } + if id := d.getU32(); id != buf.id { + t.Errorf("buffer new_id = %d", id) + } + d.getI32() // offset + if w := d.getI32(); w != 16 { + t.Errorf("buffer width = %d", w) + } + // release event flips ownership back to client. + buf.released = false + if err := buf.handle(bufferEvtRelease, nil); err != nil { + t.Fatal(err) + } + if !buf.Released() { + t.Error("release event should mark buffer released") + } + // non-release event ignored. + buf.released = false + if err := buf.handle(99, nil); err != nil { + t.Fatal(err) + } + if buf.Released() { + t.Error("unknown event should not release buffer") + } + if err := buf.Destroy(); err != nil { + t.Fatalf("buffer Destroy: %v", err) + } +} + +func TestCreateBufferSendError(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + s := &Shm{conn: c, id: 3} + pool, err := s.CreatePool(4096) + if err != nil { + t.Fatalf("CreatePool: %v", err) + } + st.writeErr = errors.New("nope") + if _, err := pool.CreateBuffer(0, 16, 16, 64, ShmFormatARGB8888); err == nil { + t.Error("CreateBuffer send error should propagate") + } + if err := pool.Destroy(); err == nil { + t.Error("Destroy send error should propagate") + } +} + +func TestBufferDestroySendError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + b := &Buffer{conn: c, id: 9, released: true} + if err := b.Destroy(); err == nil { + t.Error("buffer Destroy send error should propagate") + } +} + +func TestMin32(t *testing.T) { + if min32(3, 5) != 3 || min32(5, 3) != 3 || min32(4, 4) != 4 { + t.Error("min32 wrong") + } +} diff --git a/internal/wayland/surface.go b/internal/wayland/surface.go new file mode 100644 index 0000000..84f8286 --- /dev/null +++ b/internal/wayland/surface.go @@ -0,0 +1,138 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +// Compositor is the wl_compositor global: it creates surfaces (and regions, +// unused here). +type Compositor struct { + conn *Conn + id uint32 +} + +const compositorIfaceVersion = 4 + +// wl_compositor request opcode. +const compositorReqCreateSurface = 0 + +// bindCompositor binds the wl_compositor global from the registry. +func bindCompositor(reg *Registry, g Global) (*Compositor, error) { + ver := min32(g.Version, compositorIfaceVersion) + id, err := reg.bind(g.Name, "wl_compositor", ver) + if err != nil { + return nil, err + } + c := &Compositor{conn: reg.conn, id: id} + reg.conn.register(id, c.handle) + return c, nil +} + +// handle: wl_compositor has no events; the dispatcher is a no-op. +func (c *Compositor) handle(uint16, *decoder) error { return nil } + +// CreateSurface issues wl_compositor.create_surface and returns the surface. +func (c *Compositor) CreateSurface() (*Surface, error) { + id := c.conn.allocID() + e := newEncoder(c.conn.order) + e.putU32(id) + if err := c.conn.send(c.id, compositorReqCreateSurface, e.buf, nil); err != nil { + return nil, err + } + s := &Surface{conn: c.conn, id: id} + c.conn.register(id, s.handle) + return s, nil +} + +// Surface is a wl_surface: the drawable region attached to a shell role and +// filled from a wl_buffer. +type Surface struct { + conn *Conn + id uint32 +} + +// wl_surface request opcodes. +const ( + surfaceReqDestroy = 0 + surfaceReqAttach = 1 + surfaceReqDamage = 2 + surfaceReqFrame = 3 + surfaceReqCommit = 6 + surfaceReqDamageBuffer = 9 +) + +// wl_surface event opcodes (enter/leave carry a wl_output the client tracks +// for scale/placement; this backend ignores them). +const ( + surfaceEvtEnter = 0 + surfaceEvtLeave = 1 +) + +// ID returns the surface's object id. +func (s *Surface) ID() uint32 { return s.id } + +// handle ignores wl_surface events (enter/leave/preferred_*). +func (s *Surface) handle(uint16, *decoder) error { return nil } + +// Attach binds buf as the surface's pending content at the given offset. A +// nil buffer detaches (attaches the null object). +func (s *Surface) Attach(buf *Buffer, x, y int) error { + var bufID uint32 + if buf != nil { + bufID = buf.id + buf.released = false + } + e := newEncoder(s.conn.order) + e.putU32(bufID) + e.putI32(int32(x)) + e.putI32(int32(y)) + return s.conn.send(s.id, surfaceReqAttach, e.buf, nil) +} + +// Damage marks a rectangle of the surface (in surface coordinates) as +// changed since the last commit. +func (s *Surface) Damage(x, y, w, h int) error { + return s.rect(surfaceReqDamage, x, y, w, h) +} + +// DamageBuffer marks a rectangle in buffer coordinates as changed (the +// scale-independent damage request preferred since wl_surface v4). +func (s *Surface) DamageBuffer(x, y, w, h int) error { + return s.rect(surfaceReqDamageBuffer, x, y, w, h) +} + +// rect emits a four-int rectangle request. +func (s *Surface) rect(opcode uint16, x, y, w, h int) error { + e := newEncoder(s.conn.order) + e.putI32(int32(x)) + e.putI32(int32(y)) + e.putI32(int32(w)) + e.putI32(int32(h)) + return s.conn.send(s.id, opcode, e.buf, nil) +} + +// Commit atomically applies the pending surface state (attached buffer, +// damage, frame request) to the displayed surface. +func (s *Surface) Commit() error { + return s.conn.send(s.id, surfaceReqCommit, nil, nil) +} + +// Frame requests a throttling callback that fires when the compositor is +// ready for the next frame; the returned Callback's done event carries a +// timestamp and marks it ready. +func (s *Surface) Frame() (*Callback, error) { + cb := newCallback(s.conn) + e := newEncoder(s.conn.order) + e.putU32(cb.id) + if err := s.conn.send(s.id, surfaceReqFrame, e.buf, nil); err != nil { + return nil, err + } + return cb, nil +} + +// Destroy releases the surface object. +func (s *Surface) Destroy() error { + err := s.conn.send(s.id, surfaceReqDestroy, nil, nil) + s.conn.unregister(s.id) + return err +} diff --git a/internal/wayland/surface_test.go b/internal/wayland/surface_test.go new file mode 100644 index 0000000..33c8507 --- /dev/null +++ b/internal/wayland/surface_test.go @@ -0,0 +1,156 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "errors" + "testing" +) + +func TestCompositorAndSurface(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_compositor", Version: 4}} + + comp, err := reg.Compositor() + if err != nil { + t.Fatalf("Compositor: %v", err) + } + if err := comp.handle(0, nil); err != nil { + t.Errorf("compositor.handle = %v", err) + } + surf, err := comp.CreateSurface() + if err != nil { + t.Fatalf("CreateSurface: %v", err) + } + if surf.ID() == 0 { + t.Error("surface id should be nonzero") + } + if err := surf.handle(surfaceEvtEnter, nil); err != nil { + t.Errorf("surface.handle enter = %v", err) + } + + // Attach with a buffer marks it not-released; nil detaches. + buf := &Buffer{conn: c, id: 77, released: true} + if err := surf.Attach(buf, 1, 2); err != nil { + t.Fatalf("Attach: %v", err) + } + if buf.released { + t.Error("Attach should mark the buffer in-use") + } + obj, op, d := lastWrite(t, st, order) + if obj != surf.id || op != surfaceReqAttach { + t.Fatalf("attach obj=%d op=%d", obj, op) + } + if id := d.getU32(); id != 77 { + t.Errorf("attach buffer id = %d", id) + } + if err := surf.Attach(nil, 0, 0); err != nil { + t.Fatalf("Attach(nil): %v", err) + } + _, _, d = lastWrite(t, st, order) + if id := d.getU32(); id != 0 { + t.Errorf("detach buffer id = %d, want 0", id) + } + + if err := surf.Damage(3, 4, 5, 6); err != nil { + t.Fatalf("Damage: %v", err) + } + obj, op, d = lastWrite(t, st, order) + if op != surfaceReqDamage { + t.Fatalf("damage op=%d", op) + } + if x := d.getI32(); x != 3 { + t.Errorf("damage x = %d", x) + } + if err := surf.DamageBuffer(1, 1, 2, 2); err != nil { + t.Fatalf("DamageBuffer: %v", err) + } + if _, op, _ = lastWrite(t, st, order); op != surfaceReqDamageBuffer { + t.Fatalf("damage_buffer op=%d", op) + } + if err := surf.Commit(); err != nil { + t.Fatalf("Commit: %v", err) + } + if _, op, _ = lastWrite(t, st, order); op != surfaceReqCommit { + t.Fatalf("commit op=%d", op) + } + + cb, err := surf.Frame() + if err != nil { + t.Fatalf("Frame: %v", err) + } + if cb == nil || cb.done { + t.Error("fresh frame callback should be pending") + } + if _, op, _ = lastWrite(t, st, order); op != surfaceReqFrame { + t.Fatalf("frame op=%d", op) + } + + if err := surf.Destroy(); err != nil { + t.Fatalf("Destroy: %v", err) + } + if _, ok := c.handlers[surf.id]; ok { + t.Error("Destroy should unregister the surface") + } +} + +func TestSurfaceWriteErrors(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + s := &Surface{conn: c, id: 5} + if err := s.Attach(nil, 0, 0); err == nil { + t.Error("Attach write error") + } + if err := s.Damage(0, 0, 1, 1); err == nil { + t.Error("Damage write error") + } + if err := s.Commit(); err == nil { + t.Error("Commit write error") + } + if _, err := s.Frame(); err == nil { + t.Error("Frame write error") + } + if err := s.Destroy(); err == nil { + t.Error("Destroy write error") + } +} + +func TestCompositorCreateSurfaceWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + comp := &Compositor{conn: c, id: 3} + if _, err := comp.CreateSurface(); err == nil { + t.Error("CreateSurface write error should propagate") + } +} + +func TestCompositorBindWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_compositor", Version: 4}} + if _, err := reg.Compositor(); err == nil { + t.Error("compositor bind write error should propagate") + } +} + +func TestCompositorBindLowerVersion(t *testing.T) { + // When the compositor offers a lower version than we cap at, bind that. + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "wl_compositor", Version: 2}} + if _, err := reg.Compositor(); err != nil { + t.Fatalf("Compositor: %v", err) + } + _, _, d := lastWrite(t, st, order) + d.getU32() // name + d.getString() // interface + if v := d.getU32(); v != 2 { + t.Errorf("bound version = %d, want 2 (min of offered/cap)", v) + } +} diff --git a/internal/wayland/transport.go b/internal/wayland/transport.go new file mode 100644 index 0000000..b07634c --- /dev/null +++ b/internal/wayland/transport.go @@ -0,0 +1,159 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "fmt" + "net" + "syscall" +) + +// transport moves whole Wayland messages (already framed byte blobs) plus +// the file descriptors they carry. It is the seam that lets the protocol +// machine run either over a real UNIX socket (fds via SCM_RIGHTS) or over +// an in-process fake compositor in tests. +type transport interface { + // write sends one framed message and, out-of-band, its fds. + write(msg []byte, fds []int) error + // read returns exactly one framed message. File descriptors that + // arrive with it (or earlier) are queued and drained via popFD. + read() ([]byte, error) + // popFD returns the next received file descriptor, oldest first. + popFD() (int, bool) + // Close releases the transport. + Close() error +} + +// oobSize is the ancillary-data buffer size: room for many SCM_RIGHTS fds +// per recvmsg (Wayland passes one or two, but a burst may batch several). +var oobSize = syscall.CmsgSpace(4 * 253) + +// dataChunk is the per-recvmsg data buffer. libwayland caps a single +// message at 4096 bytes; reassembly stitches messages that span reads. +const dataChunk = 4096 + +// unixTransport is the production transport over a connected UNIX-domain +// stream socket. It reassembles the byte stream into whole messages and +// collects passed file descriptors from SCM_RIGHTS control messages. +type unixTransport struct { + c *net.UnixConn + order ByteOrder + + rbuf []byte // buffered, not-yet-consumed stream bytes + fds []int // received file descriptors, oldest first +} + +// newUnixTransport wraps a connected *net.UnixConn. +func newUnixTransport(c *net.UnixConn, order ByteOrder) *unixTransport { + return &unixTransport{c: c, order: order} +} + +// write sends msg in a single sendmsg, attaching fds as SCM_RIGHTS. A +// blocking UNIX stream socket writes the whole datagram or fails, so a +// successful call has delivered every byte and every descriptor. +func (t *unixTransport) write(msg []byte, fds []int) error { + var oob []byte + if len(fds) > 0 { + oob = syscall.UnixRights(fds...) + } + _, _, err := t.c.WriteMsgUnix(msg, oob, nil) + return err +} + +// read returns the next complete message, pulling more bytes (and fds) +// from the socket as needed. +func (t *unixTransport) read() ([]byte, error) { + for { + if msg, ok, err := t.takeMessage(); err != nil { + return nil, err + } else if ok { + return msg, nil + } + if err := t.fill(); err != nil { + return nil, err + } + } +} + +// takeMessage slices one complete message off the front of rbuf, reporting +// whether a whole message was available. A size field below the 8-byte +// header minimum is a fatal protocol error. +func (t *unixTransport) takeMessage() ([]byte, bool, error) { + if len(t.rbuf) < 8 { + return nil, false, nil + } + size := int(t.order.Uint32(t.rbuf[4:8]) >> 16) + if size < 8 { + return nil, false, fmt.Errorf("wayland: invalid message size %d", size) + } + if len(t.rbuf) < size { + return nil, false, nil + } + msg := make([]byte, size) + copy(msg, t.rbuf[:size]) + t.rbuf = t.rbuf[size:] + return msg, true, nil +} + +// fill reads one recvmsg worth of data and control bytes into the buffers. +func (t *unixTransport) fill() error { + data := make([]byte, dataChunk) + oob := make([]byte, oobSize) + n, oobn, _, _, err := t.c.ReadMsgUnix(data, oob) + if err != nil { + return err + } + t.rbuf = append(t.rbuf, data[:n]...) + if oobn > 0 { + fds, err := parseControlFDs(oob[:oobn]) + if err != nil { + return err + } + t.fds = append(t.fds, fds...) + } + return nil +} + +// parseControlFDs extracts SCM_RIGHTS file descriptors from a block of +// ancillary control data. It is a package variable so a test can force the +// (otherwise kernel-guaranteed-valid) parse to fail on the fill path. +var parseControlFDs = defaultParseControlFDs + +// defaultParseControlFDs is the real SCM_RIGHTS parser. +func defaultParseControlFDs(oob []byte) ([]int, error) { + scms, err := syscall.ParseSocketControlMessage(oob) + if err != nil { + return nil, err + } + var out []int + for i := range scms { + fds, err := syscall.ParseUnixRights(&scms[i]) + if err != nil { + return nil, err + } + out = append(out, fds...) + } + return out, nil +} + +// popFD returns the oldest received file descriptor. +func (t *unixTransport) popFD() (int, bool) { + if len(t.fds) == 0 { + return 0, false + } + fd := t.fds[0] + t.fds = t.fds[1:] + return fd, true +} + +// Close closes the socket and any still-queued received descriptors so no +// fd leaks when a session ends mid-stream. +func (t *unixTransport) Close() error { + for _, fd := range t.fds { + _ = syscall.Close(fd) + } + t.fds = nil + return t.c.Close() +} diff --git a/internal/wayland/transport_test.go b/internal/wayland/transport_test.go new file mode 100644 index 0000000..1873b7b --- /dev/null +++ b/internal/wayland/transport_test.go @@ -0,0 +1,295 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "os" + "syscall" + "testing" + "unsafe" +) + +// frame builds a raw Wayland message (header + body) in the given order. +func frame(order ByteOrder, obj uint32, opcode uint16, body []byte) []byte { + total := 8 + len(body) + e := newEncoder(order) + e.putU32(obj) + e.putU32(uint32(opcode) | uint32(total)<<16) + e.putBytes(body) + return e.buf +} + +func TestUnixTransportRoundTrip(t *testing.T) { + bothOrders(t, func(t *testing.T, order ByteOrder) { + a, b := socketPair(t) + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + defer ta.Close() + defer tb.Close() + + msg := frame(order, 3, 5, bodyOf(order, func(e *encoder) { e.putU32(42) })) + if err := ta.write(msg, nil); err != nil { + t.Fatalf("write: %v", err) + } + got, err := tb.read() + if err != nil { + t.Fatalf("read: %v", err) + } + if len(got) != len(msg) { + t.Fatalf("read %d bytes, want %d", len(got), len(msg)) + } + d := newDecoder(order, got) + if d.getU32() != 3 { + t.Error("object id mismatch") + } + }) +} + +func TestUnixTransportFDPassing(t *testing.T) { + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + defer ta.Close() + defer tb.Close() + + // Send a real descriptor (the read end of a pipe) as SCM_RIGHTS, then + // prove the received fd refers to the same open file by writing to the + // pipe's write end and reading it back through the passed fd. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer r.Close() + defer w.Close() + + msg := frame(order, 7, 1, bodyOf(order, func(e *encoder) { e.putU32(1) })) + if err := ta.write(msg, []int{int(r.Fd())}); err != nil { + t.Fatalf("write with fd: %v", err) + } + if _, err := tb.read(); err != nil { + t.Fatalf("read: %v", err) + } + fd, ok := tb.popFD() + if !ok { + t.Fatal("no fd received") + } + defer syscall.Close(fd) + // A second pop yields nothing. + if _, ok := tb.popFD(); ok { + t.Error("popFD should be empty after draining") + } + + const payload = "wayland-scm-rights" + if _, err := w.WriteString(payload); err != nil { + t.Fatalf("pipe write: %v", err) + } + buf := make([]byte, len(payload)) + n, err := syscall.Read(fd, buf) + if err != nil { + t.Fatalf("read passed fd: %v", err) + } + if string(buf[:n]) != payload { + t.Fatalf("passed fd read %q, want %q", buf[:n], payload) + } +} + +func TestUnixTransportReassembly(t *testing.T) { + // Two messages written back-to-back, delivered as one stream, must be + // split into two by the reassembler. + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + defer ta.Close() + defer tb.Close() + + m1 := frame(order, 1, 0, bodyOf(order, func(e *encoder) { e.putU32(11) })) + m2 := frame(order, 2, 0, bodyOf(order, func(e *encoder) { e.putU32(22) })) + combined := append(append([]byte{}, m1...), m2...) + if err := ta.write(combined, nil); err != nil { + t.Fatalf("write: %v", err) + } + for _, want := range []uint32{1, 2} { + got, err := tb.read() + if err != nil { + t.Fatalf("read: %v", err) + } + if id := newDecoder(order, got).getU32(); id != want { + t.Errorf("message id = %d, want %d", id, want) + } + } +} + +func TestUnixTransportReadError(t *testing.T) { + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + // Closing the writer makes the reader hit EOF. + _ = ta.Close() + if _, err := tb.read(); err == nil { + t.Error("read after peer close should error") + } + _ = tb.Close() +} + +func TestUnixTransportWriteError(t *testing.T) { + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + _ = b.Close() + _ = a.Close() + // Writing on a closed socket errors. + if err := ta.write(frame(order, 1, 0, nil), nil); err == nil { + t.Error("write on closed socket should error") + } +} + +func TestTakeMessageInvalidSize(t *testing.T) { + order := binary.LittleEndian + tr := &unixTransport{order: order} + // A header claiming size 4 (< the 8-byte minimum) is a protocol error. + e := newEncoder(order) + e.putU32(1) + e.putU32(uint32(0) | uint32(4)<<16) + tr.rbuf = e.buf + if _, _, err := tr.takeMessage(); err == nil { + t.Error("takeMessage should reject size < 8") + } +} + +func TestTakeMessagePartialHeader(t *testing.T) { + tr := &unixTransport{order: binary.LittleEndian, rbuf: []byte{1, 2, 3}} + if _, ok, err := tr.takeMessage(); ok || err != nil { + t.Errorf("partial header: ok=%v err=%v, want false,nil", ok, err) + } +} + +func TestTakeMessagePartialBody(t *testing.T) { + order := binary.LittleEndian + full := frame(order, 1, 0, bodyOf(order, func(e *encoder) { e.putU32(1) })) + tr := &unixTransport{order: order, rbuf: full[:len(full)-2]} // drop tail + if _, ok, err := tr.takeMessage(); ok || err != nil { + t.Errorf("partial body: ok=%v err=%v, want false,nil", ok, err) + } +} + +func TestReadPropagatesInvalidSize(t *testing.T) { + // read() must surface takeMessage's protocol error. + a, b := socketPair(t) + order := binary.LittleEndian + defer a.Close() + defer b.Close() + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + bad := frame(order, 1, 0, nil) + binary.LittleEndian.PutUint32(bad[4:8], uint32(0)|uint32(4)<<16) // size=4 + if err := ta.write(bad, nil); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := tb.read(); err == nil { + t.Error("read should surface invalid size") + } +} + +func TestPopFDEmpty(t *testing.T) { + tr := &unixTransport{} + if _, ok := tr.popFD(); ok { + t.Error("popFD on empty queue should be false") + } +} + +func TestParseControlFDsOversizedLen(t *testing.T) { + // A cmsghdr claiming more data than the buffer holds makes + // ParseSocketControlMessage fail. + oob := make([]byte, syscall.CmsgSpace(4)) + h := (*syscall.Cmsghdr)(unsafe.Pointer(&oob[0])) + h.Level = syscall.SOL_SOCKET + h.Type = 1 + h.SetLen(syscall.CmsgLen(4096)) // far beyond len(oob) + if _, err := defaultParseControlFDs(oob); err == nil { + t.Error("oversized cmsg len should be rejected") + } +} + +func TestParseControlFDsWrongType(t *testing.T) { + // A well-formed control message whose type is NOT SCM_RIGHTS makes + // ParseUnixRights fail. + oob := make([]byte, syscall.CmsgSpace(4)) + h := (*syscall.Cmsghdr)(unsafe.Pointer(&oob[0])) + h.Level = syscall.SOL_SOCKET + h.Type = 0 // not SCM_RIGHTS + h.SetLen(syscall.CmsgLen(4)) + if _, err := defaultParseControlFDs(oob); err == nil { + t.Error("non-SCM_RIGHTS control message should be rejected") + } +} + +func TestFillControlParseError(t *testing.T) { + // When ancillary data arrives but the parser rejects it, fill (and thus + // read) must surface the error. Force it via the injectable parser. + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + defer ta.Close() + defer tb.Close() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer r.Close() + defer w.Close() + if err := ta.write(frame(order, 1, 0, nil), []int{int(r.Fd())}); err != nil { + t.Fatalf("write: %v", err) + } + + orig := parseControlFDs + parseControlFDs = func([]byte) ([]int, error) { return nil, errForcedParse } + defer func() { parseControlFDs = orig }() + if err := tb.fill(); err == nil { + t.Error("fill should surface a control-parse error") + } +} + +var errForcedParse = errorsNew("forced parse failure") + +// errorsNew is a tiny local errors.New to avoid an extra import here. +func errorsNew(s string) error { return &simpleErr{s} } + +type simpleErr struct{ s string } + +func (e *simpleErr) Error() string { return e.s } + +func TestCloseDrainsFDs(t *testing.T) { + a, b := socketPair(t) + order := binary.LittleEndian + ta := newUnixTransport(a, order) + tb := newUnixTransport(b, order) + defer ta.Close() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer r.Close() + defer w.Close() + if err := ta.write(frame(order, 1, 0, nil), []int{int(r.Fd())}); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := tb.read(); err != nil { + t.Fatalf("read: %v", err) + } + // Close must close the still-queued received fd and the socket. + if err := tb.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if tb.fds != nil { + t.Error("Close should clear the fd queue") + } +} diff --git a/internal/wayland/wire.go b/internal/wayland/wire.go new file mode 100644 index 0000000..f791403 --- /dev/null +++ b/internal/wayland/wire.go @@ -0,0 +1,200 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +// Package wayland is a from-scratch, pure-Go (CGO-free, zero non-stdlib +// dependency) implementation of the Wayland wire protocol, spoken directly +// over a UNIX-domain stream socket. +// +// It mirrors the sovereign transport+codec approach of the sibling +// internal/x11 package and of github.com/go-freedesktop/dbus: no +// libwayland, no wayland-scanner, no cgo — the wire format is encoded and +// decoded here, byte for byte, per the Wayland protocol specification, and +// file descriptors are passed over the socket via SCM_RIGHTS ancillary +// control messages using only the Go standard library. +// +// The Wayland wire format is object-oriented. Every message is +// +// uint32 object-id (the target/sender object) +// uint32 (size<<16 | opcode) size in bytes incl. this 8-byte header +// ... typed arguments, each padded to a 4-byte boundary ... +// +// Arguments are int (i32), uint (u32), fixed (signed 24.8), string +// (length-prefixed, NUL-terminated, padded), array (length-prefixed, +// padded), object (u32 id), new_id (u32 id, optionally interface+version +// prefixed) and fd (carried out-of-band, occupying no bytes in the body). +// +// Integers travel in the host's native byte order (both peers share the +// machine), so the codec is parametrised by a binary.ByteOrder that +// defaults to binary.NativeEndian; tests drive both endian paths on any +// host, and the s390x CI lane exercises the big-endian path on real +// big-endian hardware. +package wayland + +import ( + "encoding/binary" + "math" +) + +// ByteOrder is the wire byte order. Wayland uses the machine's native +// order; NativeOrder resolves it, and the codec is parametrised so both +// paths are testable on any host. +type ByteOrder = binary.ByteOrder + +// NativeOrder is the byte order used on the wire in production: the host's +// native endianness, which both the client and the compositor share. +var NativeOrder ByteOrder = binary.NativeEndian + +// pad4 rounds n up to the next multiple of four; Wayland pads every +// variable-length argument to a 32-bit boundary. +func pad4(n int) int { return (n + 3) &^ 3 } + +// padding is the number of pad bytes following n data bytes. +func padding(n int) int { return pad4(n) - n } + +// Fixed is a Wayland 24.8 signed fixed-point number as carried on the wire +// (the raw i32 value equal to the real number times 256). +type Fixed int32 + +// FixedFromInt builds a Fixed from a whole integer. +func FixedFromInt(i int) Fixed { return Fixed(i << 8) } + +// FixedFromFloat builds a Fixed from a float64 (rounded to 1/256). +func FixedFromFloat(f float64) Fixed { return Fixed(math.Round(f * 256.0)) } + +// Int returns the truncated integer part (toward zero via arithmetic shift +// for the fractional bits; matches wl_fixed_to_int). +func (f Fixed) Int() int { return int(int32(f) >> 8) } + +// Float returns the value as a float64. +func (f Fixed) Float() float64 { return float64(int32(f)) / 256.0 } + +// encoder builds a message body in a chosen byte order. Every multi-byte +// integer goes through the ByteOrder so the same code emits a correct +// little- or big-endian stream. +type encoder struct { + order ByteOrder + buf []byte +} + +// newEncoder starts an encoder in the given order. +func newEncoder(order ByteOrder) *encoder { return &encoder{order: order} } + +func (e *encoder) putU32(v uint32) { + var b [4]byte + e.order.PutUint32(b[:], v) + e.buf = append(e.buf, b[:]...) +} + +// putI32 writes a signed 32-bit integer (two's complement). +func (e *encoder) putI32(v int32) { e.putU32(uint32(v)) } + +// putFixed writes a 24.8 fixed-point number. +func (e *encoder) putFixed(f Fixed) { e.putI32(int32(f)) } + +// putString writes a length-prefixed, NUL-terminated, 4-byte-padded +// string. The length prefix counts the trailing NUL. An empty string is +// encoded as length 1 with a single NUL, matching libwayland; a nil +// (absent) string is written by putNullString. +func (e *encoder) putString(s string) { + n := len(s) + 1 // include the trailing NUL + e.putU32(uint32(n)) + e.buf = append(e.buf, s...) + e.buf = append(e.buf, 0) // NUL + e.pad(n) +} + +// putArray writes a length-prefixed, 4-byte-padded raw byte array. +func (e *encoder) putArray(b []byte) { + e.putU32(uint32(len(b))) + e.buf = append(e.buf, b...) + e.pad(len(b)) +} + +// putBytes appends raw bytes verbatim (no length prefix, no padding). +func (e *encoder) putBytes(b []byte) { e.buf = append(e.buf, b...) } + +// pad appends the padding that follows n written bytes. +func (e *encoder) pad(n int) { + for i := 0; i < padding(n); i++ { + e.buf = append(e.buf, 0) + } +} + +// decoder reads a fixed-order message body. Every read is bounds-checked; +// once ok goes false it stays false, so a truncated buffer degrades to a +// clean error at the call site rather than a panic. +type decoder struct { + order ByteOrder + buf []byte + off int + ok bool +} + +// newDecoder wraps b for reading in the given order. +func newDecoder(order ByteOrder, b []byte) *decoder { + return &decoder{order: order, buf: b, ok: true} +} + +// need reports whether n more bytes are available, clearing ok if not. +func (d *decoder) need(n int) bool { + if !d.ok || n < 0 || d.off+n > len(d.buf) { + d.ok = false + return false + } + return true +} + +func (d *decoder) getU32() uint32 { + if !d.need(4) { + return 0 + } + v := d.order.Uint32(d.buf[d.off:]) + d.off += 4 + return v +} + +// getI32 reads a signed 32-bit integer. +func (d *decoder) getI32() int32 { return int32(d.getU32()) } + +// getFixed reads a 24.8 fixed-point number. +func (d *decoder) getFixed() Fixed { return Fixed(d.getI32()) } + +// getString reads a length-prefixed, NUL-terminated, padded string. The +// returned value excludes the trailing NUL. A zero length yields "". +func (d *decoder) getString() string { + n := int(d.getU32()) + if n == 0 { + return "" + } + if !d.need(pad4(n)) { + return "" + } + // n includes the trailing NUL; the text is the first n-1 bytes. + s := string(d.buf[d.off : d.off+n-1]) + d.off += pad4(n) + return s +} + +// getArray reads a length-prefixed, padded raw byte array (a copy). +func (d *decoder) getArray() []byte { + n := int(d.getU32()) + if n == 0 { + return []byte{} + } + if !d.need(pad4(n)) { + return nil + } + out := make([]byte, n) + copy(out, d.buf[d.off:d.off+n]) + d.off += pad4(n) + return out +} + +// orderName returns a human label for an order (used in diagnostics/tests). +func orderName(o ByteOrder) string { + if o == binary.BigEndian { + return "big" + } + return "little" +} diff --git a/internal/wayland/wire_test.go b/internal/wayland/wire_test.go new file mode 100644 index 0000000..8ef448c --- /dev/null +++ b/internal/wayland/wire_test.go @@ -0,0 +1,203 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "testing" +) + +func TestPad(t *testing.T) { + cases := []struct{ n, wantPad4, wantPadding int }{ + {0, 0, 0}, {1, 4, 3}, {2, 4, 2}, {3, 4, 1}, {4, 4, 0}, {5, 8, 3}, {8, 8, 0}, + } + for _, c := range cases { + if got := pad4(c.n); got != c.wantPad4 { + t.Errorf("pad4(%d) = %d, want %d", c.n, got, c.wantPad4) + } + if got := padding(c.n); got != c.wantPadding { + t.Errorf("padding(%d) = %d, want %d", c.n, got, c.wantPadding) + } + } +} + +func TestFixed(t *testing.T) { + if got := FixedFromInt(3); got != 768 { + t.Fatalf("FixedFromInt(3) = %d, want 768", got) + } + if got := FixedFromInt(3).Int(); got != 3 { + t.Fatalf("FixedFromInt(3).Int() = %d, want 3", got) + } + if got := FixedFromFloat(2.5); got != 640 { + t.Fatalf("FixedFromFloat(2.5) = %d, want 640", got) + } + if got := FixedFromFloat(2.5).Float(); got != 2.5 { + t.Fatalf("FixedFromFloat(2.5).Float() = %v, want 2.5", got) + } + // Negative values round-trip (arithmetic shift keeps the sign). + if got := FixedFromInt(-2).Int(); got != -2 { + t.Fatalf("FixedFromInt(-2).Int() = %d, want -2", got) + } + if got := FixedFromFloat(-1.25).Float(); got != -1.25 { + t.Fatalf("FixedFromFloat(-1.25).Float() = %v, want -1.25", got) + } +} + +func TestEncodeDecodeScalars(t *testing.T) { + bothOrders(t, func(t *testing.T, order ByteOrder) { + e := newEncoder(order) + e.putU32(0xdeadbeef) + e.putI32(-12345) + e.putFixed(FixedFromInt(7)) + d := newDecoder(order, e.buf) + if got := d.getU32(); got != 0xdeadbeef { + t.Errorf("getU32 = %#x", got) + } + if got := d.getI32(); got != -12345 { + t.Errorf("getI32 = %d", got) + } + if got := d.getFixed(); got.Int() != 7 { + t.Errorf("getFixed = %d", got.Int()) + } + if !d.ok { + t.Error("decoder not ok after exact reads") + } + }) +} + +func TestEncodeDecodeString(t *testing.T) { + bothOrders(t, func(t *testing.T, order ByteOrder) { + for _, s := range []string{"", "a", "wl_compositor", "xdg_wm_base"} { + e := newEncoder(order) + e.putString(s) + // The encoded length is len+1 (NUL), padded to 4. + if len(e.buf)%4 != 0 { + t.Errorf("string %q not 4-padded: %d bytes", s, len(e.buf)) + } + d := newDecoder(order, e.buf) + if got := d.getString(); got != s { + t.Errorf("string round-trip %q -> %q", s, got) + } + if !d.ok { + t.Errorf("decoder not ok after string %q", s) + } + } + }) +} + +func TestDecodeStringZeroLength(t *testing.T) { + // A wire length of 0 denotes a null string, decoded as "". + order := binary.LittleEndian + e := newEncoder(order) + e.putU32(0) + d := newDecoder(order, e.buf) + if got := d.getString(); got != "" { + t.Errorf("zero-length string = %q, want empty", got) + } +} + +func TestDecodeStringTruncated(t *testing.T) { + order := binary.LittleEndian + e := newEncoder(order) + e.putU32(16) // claims 16 bytes but supplies none + d := newDecoder(order, e.buf) + if got := d.getString(); got != "" { + t.Errorf("truncated string = %q, want empty", got) + } + if d.ok { + t.Error("decoder should be not-ok after truncated string") + } +} + +func TestEncodeDecodeArray(t *testing.T) { + bothOrders(t, func(t *testing.T, order ByteOrder) { + for _, a := range [][]byte{{}, {1}, {1, 2, 3}, {1, 2, 3, 4, 5}} { + e := newEncoder(order) + e.putArray(a) + if len(e.buf)%4 != 0 { + t.Errorf("array len %d not 4-padded: %d bytes", len(a), len(e.buf)) + } + d := newDecoder(order, e.buf) + got := d.getArray() + if len(got) != len(a) { + t.Fatalf("array round-trip len %d -> %d", len(a), len(got)) + } + for i := range a { + if got[i] != a[i] { + t.Errorf("array[%d] = %d, want %d", i, got[i], a[i]) + } + } + } + }) +} + +func TestDecodeArrayTruncated(t *testing.T) { + order := binary.LittleEndian + e := newEncoder(order) + e.putU32(12) // claims 12 bytes, supplies none + d := newDecoder(order, e.buf) + if got := d.getArray(); got != nil { + t.Errorf("truncated array = %v, want nil", got) + } + if d.ok { + t.Error("decoder should be not-ok after truncated array") + } +} + +func TestDecodeScalarTruncated(t *testing.T) { + order := binary.LittleEndian + d := newDecoder(order, []byte{1, 2}) // fewer than 4 bytes + if got := d.getU32(); got != 0 { + t.Errorf("truncated getU32 = %#x, want 0", got) + } + if d.ok { + t.Error("decoder should be not-ok after truncated u32") + } + // Once not-ok, further reads stay 0 and not-ok. + if got := d.getU32(); got != 0 || d.ok { + t.Error("decoder should stay not-ok") + } +} + +func TestNeedNegative(t *testing.T) { + d := newDecoder(binary.LittleEndian, []byte{1, 2, 3, 4}) + if d.need(-1) { + t.Error("need(-1) should be false") + } + if d.ok { + t.Error("need(-1) should clear ok") + } +} + +func TestPutBytesAndPad(t *testing.T) { + e := newEncoder(binary.LittleEndian) + e.putBytes([]byte{9, 9, 9}) + e.pad(3) + if len(e.buf) != 4 || e.buf[3] != 0 { + t.Fatalf("putBytes+pad = %v", e.buf) + } +} + +func TestOrderName(t *testing.T) { + if orderName(binary.BigEndian) != "big" { + t.Error("big endian name") + } + if orderName(binary.LittleEndian) != "little" { + t.Error("little endian name") + } +} + +func TestNativeOrderResolves(t *testing.T) { + if NativeOrder == nil { + t.Fatal("NativeOrder must be set") + } + // It round-trips a 32-bit value like any ByteOrder (it is + // binary.NativeEndian, a distinct type from Little/BigEndian). + e := newEncoder(NativeOrder) + e.putU32(0x01020304) + if got := newDecoder(NativeOrder, e.buf).getU32(); got != 0x01020304 { + t.Fatalf("NativeOrder round-trip = %#x", got) + } +} diff --git a/internal/wayland/xdgshell.go b/internal/wayland/xdgshell.go new file mode 100644 index 0000000..f4f845b --- /dev/null +++ b/internal/wayland/xdgshell.go @@ -0,0 +1,224 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import "fmt" + +// XdgWmBase is the xdg_wm_base global (stable xdg-shell): the factory for +// window-manager surface roles. It answers the compositor's liveness pings +// automatically so the window is never declared unresponsive. +type XdgWmBase struct { + conn *Conn + id uint32 +} + +const xdgWmBaseIfaceVersion = 4 + +// xdg_wm_base request opcodes. +const ( + xdgWmBaseReqDestroy = 0 + xdgWmBaseReqGetXdgSurface = 2 + xdgWmBaseReqPong = 3 +) + +// xdg_wm_base event opcode. +const xdgWmBaseEvtPing = 0 + +// bindXdgWmBase binds the xdg_wm_base global from the registry. +func bindXdgWmBase(reg *Registry, g Global) (*XdgWmBase, error) { + ver := min32(g.Version, xdgWmBaseIfaceVersion) + id, err := reg.bind(g.Name, "xdg_wm_base", ver) + if err != nil { + return nil, err + } + b := &XdgWmBase{conn: reg.conn, id: id} + reg.conn.register(id, b.handle) + return b, nil +} + +// handle answers ping with pong; other events are ignored. +func (b *XdgWmBase) handle(opcode uint16, d *decoder) error { + if opcode != xdgWmBaseEvtPing { + return nil + } + serial := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated xdg_wm_base.ping") + } + return b.Pong(serial) +} + +// Pong answers a liveness ping. +func (b *XdgWmBase) Pong(serial uint32) error { + e := newEncoder(b.conn.order) + e.putU32(serial) + return b.conn.send(b.id, xdgWmBaseReqPong, e.buf, nil) +} + +// GetXdgSurface gives a wl_surface the xdg_surface role. +func (b *XdgWmBase) GetXdgSurface(surf *Surface) (*XdgSurface, error) { + id := b.conn.allocID() + e := newEncoder(b.conn.order) + e.putU32(id) + e.putU32(surf.id) + if err := b.conn.send(b.id, xdgWmBaseReqGetXdgSurface, e.buf, nil); err != nil { + return nil, err + } + xs := &XdgSurface{conn: b.conn, id: id, surface: surf} + b.conn.register(id, xs.handle) + return xs, nil +} + +// Destroy releases the xdg_wm_base object. +func (b *XdgWmBase) Destroy() error { + err := b.conn.send(b.id, xdgWmBaseReqDestroy, nil, nil) + b.conn.unregister(b.id) + return err +} + +// XdgSurface adds window-manager semantics (configure/ack) to a wl_surface. +type XdgSurface struct { + conn *Conn + id uint32 + surface *Surface + // OnConfigure, if set, is called with each configure serial. The window + // layer acks it (after applying any toplevel size) via AckConfigure. + OnConfigure func(serial uint32) + lastSerial uint32 + configured bool +} + +// xdg_surface request opcodes. +const ( + xdgSurfaceReqDestroy = 0 + xdgSurfaceReqGetToplevel = 1 + xdgSurfaceReqAckConfigure = 4 +) + +// xdg_surface event opcode. +const xdgSurfaceEvtConfigure = 0 + +// handle records the configure serial, marks the surface configured and +// notifies the window layer. +func (xs *XdgSurface) handle(opcode uint16, d *decoder) error { + if opcode != xdgSurfaceEvtConfigure { + return nil + } + serial := d.getU32() + if !d.ok { + return fmt.Errorf("wayland: truncated xdg_surface.configure") + } + xs.lastSerial = serial + xs.configured = true + if xs.OnConfigure != nil { + xs.OnConfigure(serial) + } + return nil +} + +// Configured reports whether the compositor has sent the first configure. +func (xs *XdgSurface) Configured() bool { return xs.configured } + +// LastSerial is the most recent configure serial. +func (xs *XdgSurface) LastSerial() uint32 { return xs.lastSerial } + +// AckConfigure acknowledges a configure serial; the client must do this +// before committing the buffer that satisfies the configure. +func (xs *XdgSurface) AckConfigure(serial uint32) error { + e := newEncoder(xs.conn.order) + e.putU32(serial) + return xs.conn.send(xs.id, xdgSurfaceReqAckConfigure, e.buf, nil) +} + +// GetToplevel gives the xdg_surface the toplevel (application window) role. +func (xs *XdgSurface) GetToplevel() (*XdgToplevel, error) { + id := xs.conn.allocID() + e := newEncoder(xs.conn.order) + e.putU32(id) + if err := xs.conn.send(xs.id, xdgSurfaceReqGetToplevel, e.buf, nil); err != nil { + return nil, err + } + tl := &XdgToplevel{conn: xs.conn, id: id} + xs.conn.register(id, tl.handle) + return tl, nil +} + +// Destroy releases the xdg_surface object. +func (xs *XdgSurface) Destroy() error { + err := xs.conn.send(xs.id, xdgSurfaceReqDestroy, nil, nil) + xs.conn.unregister(xs.id) + return err +} + +// XdgToplevel is the application-window role: it carries the title/app-id +// and delivers resize (configure) and close intents. +type XdgToplevel struct { + conn *Conn + id uint32 + // OnConfigure is called with the compositor-suggested size (0 means "you + // choose") and the raw states array. The window layer resizes to it. + OnConfigure func(width, height int, states []byte) + // OnClose is called when the user asks to close the window. + OnClose func() +} + +// xdg_toplevel request opcodes. +const ( + xdgToplevelReqDestroy = 0 + xdgToplevelReqSetTitle = 2 + xdgToplevelReqSetAppID = 3 +) + +// xdg_toplevel event opcodes. +const ( + xdgToplevelEvtConfigure = 0 + xdgToplevelEvtClose = 1 +) + +// handle dispatches configure (size) and close. +func (tl *XdgToplevel) handle(opcode uint16, d *decoder) error { + switch opcode { + case xdgToplevelEvtConfigure: + w := d.getI32() + h := d.getI32() + states := d.getArray() + if !d.ok { + return fmt.Errorf("wayland: truncated xdg_toplevel.configure") + } + if tl.OnConfigure != nil { + tl.OnConfigure(int(w), int(h), states) + } + return nil + case xdgToplevelEvtClose: + if tl.OnClose != nil { + tl.OnClose() + } + return nil + default: + return nil + } +} + +// SetTitle sets the window title. +func (tl *XdgToplevel) SetTitle(title string) error { + e := newEncoder(tl.conn.order) + e.putString(title) + return tl.conn.send(tl.id, xdgToplevelReqSetTitle, e.buf, nil) +} + +// SetAppID sets the application identifier (used for grouping / .desktop +// matching). +func (tl *XdgToplevel) SetAppID(appID string) error { + e := newEncoder(tl.conn.order) + e.putString(appID) + return tl.conn.send(tl.id, xdgToplevelReqSetAppID, e.buf, nil) +} + +// Destroy releases the xdg_toplevel object. +func (tl *XdgToplevel) Destroy() error { + err := tl.conn.send(tl.id, xdgToplevelReqDestroy, nil, nil) + tl.conn.unregister(tl.id) + return err +} diff --git a/internal/wayland/xdgshell_test.go b/internal/wayland/xdgshell_test.go new file mode 100644 index 0000000..6f2f505 --- /dev/null +++ b/internal/wayland/xdgshell_test.go @@ -0,0 +1,220 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "encoding/binary" + "errors" + "testing" +) + +func TestXdgWmBase(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "xdg_wm_base", Version: 3}} + + wm, err := reg.XdgWmBase() + if err != nil { + t.Fatalf("XdgWmBase: %v", err) + } + // ping -> pong with the same serial. + if err := wm.handle(xdgWmBaseEvtPing, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(0x1234) }))); err != nil { + t.Fatalf("ping: %v", err) + } + obj, op, d := lastWrite(t, st, order) + if obj != wm.id || op != xdgWmBaseReqPong { + t.Fatalf("pong obj=%d op=%d", obj, op) + } + if s := d.getU32(); s != 0x1234 { + t.Errorf("pong serial = %#x", s) + } + // unknown event ignored, truncated ping errors. + if err := wm.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown wm event = %v", err) + } + if err := wm.handle(xdgWmBaseEvtPing, newDecoder(order, nil)); err == nil { + t.Error("truncated ping should error") + } + + if err := wm.Destroy(); err != nil { + t.Fatalf("Destroy: %v", err) + } + if _, ok := c.handlers[wm.id]; ok { + t.Error("Destroy should unregister wm_base") + } +} + +func TestXdgWmBaseBindWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + reg := &Registry{conn: c} + reg.globals = []Global{{Name: 1, Interface: "xdg_wm_base", Version: 3}} + if _, err := reg.XdgWmBase(); err == nil { + t.Error("xdg_wm_base bind write error should propagate") + } +} + +func TestXdgWmBasePongWriteError(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + wm := &XdgWmBase{conn: c, id: 3} + if err := wm.Pong(1); err == nil { + t.Error("Pong write error should propagate") + } + if _, err := wm.GetXdgSurface(&Surface{conn: c, id: 4}); err == nil { + t.Error("GetXdgSurface write error should propagate") + } + if err := wm.Destroy(); err == nil { + t.Error("Destroy write error should propagate") + } +} + +func TestXdgSurface(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + wm := &XdgWmBase{conn: c, id: 3} + surf := &Surface{conn: c, id: 5} + xs, err := wm.GetXdgSurface(surf) + if err != nil { + t.Fatalf("GetXdgSurface: %v", err) + } + if xs.Configured() { + t.Error("not configured before first configure") + } + + var gotSerial uint32 + xs.OnConfigure = func(s uint32) { gotSerial = s } + if err := xs.handle(xdgSurfaceEvtConfigure, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(0xABCD) }))); err != nil { + t.Fatalf("configure: %v", err) + } + if !xs.Configured() || xs.LastSerial() != 0xABCD || gotSerial != 0xABCD { + t.Fatalf("configure state serial=%#x configured=%v cb=%#x", xs.LastSerial(), xs.Configured(), gotSerial) + } + // ack. + if err := xs.AckConfigure(0xABCD); err != nil { + t.Fatalf("AckConfigure: %v", err) + } + if obj, op, d := lastWrite(t, st, order); obj != xs.id || op != xdgSurfaceReqAckConfigure || d.getU32() != 0xABCD { + t.Errorf("ack mismatch obj=%d op=%d", obj, op) + } + // unknown event ignored; truncated configure errors. + if err := xs.handle(99, newDecoder(order, nil)); err != nil { + t.Errorf("unknown xdg_surface event = %v", err) + } + if err := xs.handle(xdgSurfaceEvtConfigure, newDecoder(order, nil)); err == nil { + t.Error("truncated configure should error") + } + // configure with no OnConfigure set still works. + xs.OnConfigure = nil + if err := xs.handle(xdgSurfaceEvtConfigure, newDecoder(order, bodyOf(order, func(e *encoder) { e.putU32(1) }))); err != nil { + t.Fatal(err) + } + + tl, err := xs.GetToplevel() + if err != nil { + t.Fatalf("GetToplevel: %v", err) + } + if tl.id == 0 { + t.Error("toplevel id should be nonzero") + } + if err := xs.Destroy(); err != nil { + t.Fatalf("Destroy: %v", err) + } +} + +func TestXdgSurfaceWriteErrors(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + xs := &XdgSurface{conn: c, id: 6} + if err := xs.AckConfigure(1); err == nil { + t.Error("AckConfigure write error") + } + if _, err := xs.GetToplevel(); err == nil { + t.Error("GetToplevel write error") + } + if err := xs.Destroy(); err == nil { + t.Error("Destroy write error") + } +} + +func TestXdgToplevel(t *testing.T) { + order := binary.LittleEndian + st := &stubTransport{} + c := NewConn(st, order) + tl := &XdgToplevel{conn: c, id: 7} + + if err := tl.SetTitle("hello"); err != nil { + t.Fatalf("SetTitle: %v", err) + } + if obj, op, d := lastWrite(t, st, order); obj != tl.id || op != xdgToplevelReqSetTitle || d.getString() != "hello" { + t.Errorf("set_title mismatch obj=%d op=%d", obj, op) + } + if err := tl.SetAppID("app.id"); err != nil { + t.Fatalf("SetAppID: %v", err) + } + if _, op, d := lastWrite(t, st, order); op != xdgToplevelReqSetAppID || d.getString() != "app.id" { + t.Errorf("set_app_id mismatch op=%d", op) + } + + // configure delivers size + states. + var gotW, gotH int + var gotStates int + tl.OnConfigure = func(w, h int, states []byte) { gotW, gotH, gotStates = w, h, len(states) } + body := bodyOf(order, func(e *encoder) { + e.putI32(800) + e.putI32(600) + e.putArray([]byte{4, 0, 0, 0}) // one state word + }) + if err := tl.handle(xdgToplevelEvtConfigure, newDecoder(order, body)); err != nil { + t.Fatalf("configure: %v", err) + } + if gotW != 800 || gotH != 600 || gotStates != 4 { + t.Fatalf("configure got %dx%d states=%d", gotW, gotH, gotStates) + } + // configure with nil callback is fine. + tl.OnConfigure = nil + if err := tl.handle(xdgToplevelEvtConfigure, newDecoder(order, body)); err != nil { + t.Fatal(err) + } + + // close. + closed := false + tl.OnClose = func() { closed = true } + if err := tl.handle(xdgToplevelEvtClose, nil); err != nil { + t.Fatalf("close: %v", err) + } + if !closed { + t.Error("OnClose not invoked") + } + tl.OnClose = nil + if err := tl.handle(xdgToplevelEvtClose, nil); err != nil { + t.Fatal(err) + } + // unknown event ignored; truncated configure errors. + if err := tl.handle(99, nil); err != nil { + t.Errorf("unknown toplevel event = %v", err) + } + if err := tl.handle(xdgToplevelEvtConfigure, newDecoder(order, nil)); err == nil { + t.Error("truncated configure should error") + } + + if err := tl.Destroy(); err != nil { + t.Fatalf("Destroy: %v", err) + } +} + +func TestXdgToplevelWriteErrors(t *testing.T) { + c := NewConn(&stubTransport{writeErr: errors.New("nope")}, binary.LittleEndian) + tl := &XdgToplevel{conn: c, id: 7} + if err := tl.SetTitle("x"); err == nil { + t.Error("SetTitle write error") + } + if err := tl.SetAppID("x"); err == nil { + t.Error("SetAppID write error") + } + if err := tl.Destroy(); err == nil { + t.Error("Destroy write error") + } +} diff --git a/internal/wayland/xkb.go b/internal/wayland/xkb.go new file mode 100644 index 0000000..edb3c86 --- /dev/null +++ b/internal/wayland/xkb.go @@ -0,0 +1,239 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import ( + "regexp" + "strconv" + "strings" +) + +// A Wayland compositor hands the keyboard layout to the client as an +// xkb_v1 keymap: a text document, delivered over a shared-memory fd, that +// declares (among other sections) which keysym names each hardware key +// produces at each shift level. This file parses enough of that text — +// the xkb_keycodes and xkb_symbols sections — to turn a hardware key event +// into a keysym, and then a keysym name into either a printable rune or a +// toolkit key name. +// +// The mapping is deliberately minimal but functional: it covers the ASCII +// letters and digits, the common punctuation keysym names, the navigation +// and editing keys, and the modifier keys. Symbols outside that set yield +// no rune (the key is silently ignored), which is the documented, honest +// scope of this sovereign parser. + +// Key is the resolved meaning of a hardware key at a given shift level. +type Key struct { + // Name is a toolkit key name for a non-character key ("Enter", + // "ArrowLeft", ...), or "" for a character / modifier key. + Name string + // Rune is the committed character for a printable key; valid only when + // HasRune is true. + Rune rune + // HasRune reports whether Rune is meaningful. + HasRune bool + // IsModifier reports a modifier key (Shift/Control/Alt/...); such keys + // deliver no toolkit event. + IsModifier bool +} + +// Keymap is a parsed xkb keymap: the per-keycode list of level keysym names +// (group 1 only), keyed by xkb keycode. +type Keymap struct { + codeSyms map[uint32][]string +} + +// evdevOffset is the constant added to a Linux evdev keycode to get the xkb +// keycode the keymap's xkb_keycodes section is written in. +const evdevOffset = 8 + +var ( + reLineComment = regexp.MustCompile(`//[^\n]*`) + reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) + reKeycode = regexp.MustCompile(`<([^>]+)>\s*=\s*(\d+)`) + reKeyStmt = regexp.MustCompile(`(?s)key\s*<([^>]+)>\s*\{([^}]*)\}`) + reBrackets = regexp.MustCompile(`\[([^\]]*)\]`) + reGroupIdx = regexp.MustCompile(`^Group\d+$`) +) + +// ParseKeymap parses an xkb_v1 keymap document. An empty or unparsable +// document yields a Keymap that resolves every key to nothing (safe: keys +// simply produce no events), so a compositor sending a keymap this minimal +// parser does not understand degrades gracefully rather than crashing. +func ParseKeymap(text string) *Keymap { + km := &Keymap{codeSyms: map[uint32][]string{}} + clean := reBlockComment.ReplaceAllString(reLineComment.ReplaceAllString(text, ""), "") + + names := map[string]uint32{} // xkb key name -> xkb keycode + for _, m := range reKeycode.FindAllStringSubmatch(sectionBody(clean, "xkb_keycodes"), -1) { + if code, err := strconv.ParseUint(m[2], 10, 32); err == nil { + names[m[1]] = uint32(code) + } + } + + symbols := sectionBody(clean, "xkb_symbols") + for _, m := range reKeyStmt.FindAllStringSubmatch(symbols, -1) { + keyName, body := m[1], m[2] + levels := firstSymbolList(body) + if levels == nil { + continue + } + if code, ok := names[keyName]; ok { + km.codeSyms[code] = levels + } + } + return km +} + +// sectionBody returns the brace-delimited body that follows the first +// occurrence of keyword, matching nested braces. It returns "" if the +// keyword or its opening brace is absent. +func sectionBody(text, keyword string) string { + i := strings.Index(text, keyword) + if i < 0 { + return "" + } + rel := strings.IndexByte(text[i:], '{') + if rel < 0 { + return "" + } + start := i + rel + 1 + depth := 1 + for k := start; k < len(text); k++ { + switch text[k] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return text[start:k] + } + } + } + return text[start:] +} + +// firstSymbolList extracts the first bracketed list in a key body that is a +// real symbol list (skipping index brackets like "[Group1]"), returning the +// trimmed, comma-split level names. It returns nil if none is present. +func firstSymbolList(body string) []string { + for _, b := range reBrackets.FindAllStringSubmatch(body, -1) { + content := strings.TrimSpace(b[1]) + if content == "" || reGroupIdx.MatchString(content) { + continue + } + parts := strings.Split(content, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, strings.TrimSpace(p)) + } + return out + } + return nil +} + +// Lookup resolves an evdev keycode at the given shift level into a Key. An +// unknown keycode or empty level yields the zero Key (nothing to deliver). +func (km *Keymap) Lookup(evdevCode uint32, shift bool) Key { + levels := km.codeSyms[evdevCode+evdevOffset] + if len(levels) == 0 { + return Key{} + } + level := 0 + if shift && len(levels) > 1 { + level = 1 + } + return resolveKeysym(levels[level]) +} + +// resolveKeysym maps an xkb keysym name to its Key meaning. +func resolveKeysym(name string) Key { + if modifierKeysyms[name] { + return Key{IsModifier: true} + } + if tk, ok := namedKeysyms[name]; ok { + return Key{Name: tk} + } + if r, ok := punctKeysyms[name]; ok { + return Key{Rune: r, HasRune: true} + } + if len(name) == 1 { + c := name[0] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + return Key{Rune: rune(c), HasRune: true} + } + } + return Key{} +} + +// namedKeysyms maps non-character xkb keysym names to toolkit key names. +var namedKeysyms = map[string]string{ + "Return": "Enter", + "KP_Enter": "Enter", + "BackSpace": "Backspace", + "Tab": "Tab", + "ISO_Left_Tab": "Tab", + "Escape": "Escape", + "Delete": "Delete", + "Insert": "Insert", + "Left": "ArrowLeft", + "Right": "ArrowRight", + "Up": "ArrowUp", + "Down": "ArrowDown", + "Home": "Home", + "End": "End", + "Prior": "PageUp", + "Next": "PageDown", +} + +// modifierKeysyms is the set of modifier key names, which deliver no event. +var modifierKeysyms = map[string]bool{ + "Shift_L": true, "Shift_R": true, + "Control_L": true, "Control_R": true, + "Alt_L": true, "Alt_R": true, + "Super_L": true, "Super_R": true, + "Meta_L": true, "Meta_R": true, + "Hyper_L": true, "Hyper_R": true, + "Caps_Lock": true, "Num_Lock": true, "Shift_Lock": true, + "ISO_Level3_Shift": true, "ISO_Level5_Shift": true, + "Mode_switch": true, +} + +// punctKeysyms maps symbolic punctuation/space keysym names to their runes. +var punctKeysyms = map[string]rune{ + "space": ' ', + "exclam": '!', + "quotedbl": '"', + "numbersign": '#', + "dollar": '$', + "percent": '%', + "ampersand": '&', + "apostrophe": '\'', + "parenleft": '(', + "parenright": ')', + "asterisk": '*', + "plus": '+', + "comma": ',', + "minus": '-', + "period": '.', + "slash": '/', + "colon": ':', + "semicolon": ';', + "less": '<', + "equal": '=', + "greater": '>', + "question": '?', + "at": '@', + "bracketleft": '[', + "backslash": '\\', + "bracketright": ']', + "asciicircum": '^', + "underscore": '_', + "grave": '`', + "braceleft": '{', + "bar": '|', + "braceright": '}', + "asciitilde": '~', +} diff --git a/internal/wayland/xkb_test.go b/internal/wayland/xkb_test.go new file mode 100644 index 0000000..af8cbd2 --- /dev/null +++ b/internal/wayland/xkb_test.go @@ -0,0 +1,144 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package wayland + +import "testing" + +const kmText = ` +// leading line comment +xkb_keymap { +xkb_keycodes "evdev" { + minimum = 8; + maximum = 255; + = 38; + = 36; + = 50; + = 65; + = 10; + = 99999999999; +}; +xkb_types "x" { /* block comment */ }; +xkb_symbols "pc" { + key { [ a, A ] }; + key { [ Return ] }; + key { [ Shift_L ] }; + key { [ space ] }; + key { type[Group1]="FOUR", symbols[Group1] = [ 1, exclam ] }; + key { [ b ] }; + key { type[Group1]="ONE" }; +}; +}; +` + +func TestParseKeymapLookup(t *testing.T) { + km := ParseKeymap(kmText) + cases := []struct { + evdev uint32 + shift bool + wantRune rune + wantName string + wantMod bool + wantNothin bool + }{ + {30, false, 'a', "", false, false}, // level 0 + {30, true, 'A', "", false, false}, // level 1 + {28, false, 0, "Enter", false, false}, // named + {28, true, 0, "Enter", false, false}, // single level: shift ignored + {42, false, 0, "", true, false}, // Shift_L modifier + {57, false, ' ', "", false, false}, // space rune + {2, false, '1', "", false, false}, // level 0 + {2, true, '!', "", false, false}, // exclam + {200, false, 0, "", false, true}, // unknown keycode + } + for _, c := range cases { + k := km.Lookup(c.evdev, c.shift) + if c.wantNothin { + if k.HasRune || k.Name != "" || k.IsModifier { + t.Errorf("Lookup(%d) = %+v, want nothing", c.evdev, k) + } + continue + } + if c.wantMod { + if !k.IsModifier { + t.Errorf("Lookup(%d) not modifier", c.evdev) + } + continue + } + if c.wantName != "" { + if k.Name != c.wantName { + t.Errorf("Lookup(%d) name = %q, want %q", c.evdev, k.Name, c.wantName) + } + continue + } + if !k.HasRune || k.Rune != c.wantRune { + t.Errorf("Lookup(%d,shift=%v) rune = %q, want %q", c.evdev, c.shift, k.Rune, c.wantRune) + } + } +} + +func TestParseKeymapEmpty(t *testing.T) { + km := ParseKeymap("") + if k := km.Lookup(30, false); k.HasRune || k.Name != "" || k.IsModifier { + t.Errorf("empty keymap Lookup = %+v, want nothing", k) + } +} + +func TestResolveKeysym(t *testing.T) { + if k := resolveKeysym("Shift_L"); !k.IsModifier { + t.Error("Shift_L should be modifier") + } + if k := resolveKeysym("BackSpace"); k.Name != "Backspace" { + t.Errorf("BackSpace -> %q", k.Name) + } + if k := resolveKeysym("period"); !k.HasRune || k.Rune != '.' { + t.Errorf("period -> %q", k.Rune) + } + if k := resolveKeysym("Z"); !k.HasRune || k.Rune != 'Z' { + t.Errorf("Z -> %q", k.Rune) + } + if k := resolveKeysym("7"); !k.HasRune || k.Rune != '7' { + t.Errorf("7 -> %q", k.Rune) + } + // A single non-alphanumeric char name is not resolvable to a rune here. + if k := resolveKeysym("$"); k.HasRune { + t.Errorf("$ single-char should not resolve, got %q", k.Rune) + } + // An unknown multi-char symbol resolves to nothing. + if k := resolveKeysym("Foobar"); k.HasRune || k.Name != "" || k.IsModifier { + t.Errorf("Foobar -> %+v, want nothing", k) + } +} + +func TestSectionBody(t *testing.T) { + if got := sectionBody("no keyword here", "xkb_symbols"); got != "" { + t.Errorf("missing keyword -> %q", got) + } + if got := sectionBody("xkb_symbols no brace", "xkb_symbols"); got != "" { + t.Errorf("missing brace -> %q", got) + } + if got := sectionBody("xkb_symbols { a { b } c }", "xkb_symbols"); got != " a { b } c " { + t.Errorf("nested braces -> %q", got) + } + // Unbalanced: no closing brace returns the remainder. + if got := sectionBody("xkb_symbols { unclosed", "xkb_symbols"); got != " unclosed" { + t.Errorf("unbalanced -> %q", got) + } +} + +func TestFirstSymbolList(t *testing.T) { + if got := firstSymbolList("type[Group1]=\"X\""); got != nil { + t.Errorf("index-only body -> %v, want nil", got) + } + if got := firstSymbolList("no brackets"); got != nil { + t.Errorf("no brackets -> %v", got) + } + if got := firstSymbolList("[]"); got != nil { + t.Errorf("empty brackets -> %v, want nil", got) + } + got := firstSymbolList("[ a , B ]") + if len(got) != 2 || got[0] != "a" || got[1] != "B" { + t.Errorf("symbol list -> %v", got) + } +} diff --git a/open_linux.go b/open_linux.go index e421294..53bcbc0 100644 --- a/open_linux.go +++ b/open_linux.go @@ -11,20 +11,73 @@ import ( "fmt" "net" "os" + "path/filepath" + "github.com/go-widgets/window/internal/wayland" "github.com/go-widgets/window/internal/x11" ) -// Open connects to the X11 server named by cfg.Display (or $DISPLAY), +// Open connects to the running display server and returns a window ready for +// Run. It auto-selects the backend: Wayland when $WAYLAND_DISPLAY is set +// (the modern default on contemporary Linux desktops), otherwise the X11 +// backend driven by $DISPLAY. Both are sovereign, pure-Go, CGO-free +// implementations of their wire protocols. +func Open(cfg Config) (Backend, error) { + if name := os.Getenv("WAYLAND_DISPLAY"); name != "" { + return openWayland(cfg, name) + } + return openX11(cfg) +} + +// openWayland dials the compositor socket named by $WAYLAND_DISPLAY (resolved +// against $XDG_RUNTIME_DIR) and brings up an xdg-shell toplevel. +func openWayland(cfg Config, name string) (Backend, error) { + path, err := waylandSocketPath(name) + if err != nil { + return nil, err + } + nc, err := net.Dial("unix", path) + if err != nil { + return nil, fmt.Errorf("window: cannot connect to Wayland compositor: %w", err) + } + uc, ok := nc.(*net.UnixConn) + if !ok { // net.Dial("unix", ...) always yields *net.UnixConn + nc.Close() + return nil, fmt.Errorf("window: Wayland dial returned %T, want *net.UnixConn", nc) + } + conn := wayland.New(uc) + w, err := newWaylandWindow(conn, cfg) + if err != nil { + conn.Close() + return nil, err + } + return w, nil +} + +// waylandSocketPath resolves the compositor socket path. An absolute +// $WAYLAND_DISPLAY is used verbatim; a bare name is joined onto +// $XDG_RUNTIME_DIR. +func waylandSocketPath(name string) (string, error) { + if filepath.IsAbs(name) { + return name, nil + } + dir := os.Getenv("XDG_RUNTIME_DIR") + if dir == "" { + return "", fmt.Errorf("window: XDG_RUNTIME_DIR is not set (needed for WAYLAND_DISPLAY=%q)", name) + } + return filepath.Join(dir, name), nil +} + +// openX11 connects to the X11 server named by cfg.Display (or $DISPLAY), // authenticates with the matching MIT-MAGIC-COOKIE-1 from the Xauthority // file, creates and maps a window and returns it ready for Run. -func Open(cfg Config) (*Window, error) { +func openX11(cfg Config) (Backend, error) { disp := cfg.Display if disp == "" { disp = os.Getenv("DISPLAY") } if disp == "" { - return nil, fmt.Errorf("window: DISPLAY is not set") + return nil, fmt.Errorf("window: neither WAYLAND_DISPLAY nor DISPLAY is set") } d, err := parseDisplay(disp) if err != nil { diff --git a/open_linux_test.go b/open_linux_test.go index 4a1b1bb..60c6056 100644 --- a/open_linux_test.go +++ b/open_linux_test.go @@ -11,6 +11,8 @@ import ( ) func TestOpenErrors(t *testing.T) { + // With WAYLAND_DISPLAY unset, Open falls through to the X11 backend. + t.Setenv("WAYLAND_DISPLAY", "") // DISPLAY unset (and none supplied) errors. t.Setenv("DISPLAY", "") if _, err := Open(Config{}); err == nil { @@ -30,6 +32,41 @@ func TestOpenErrors(t *testing.T) { } } +func TestOpenSelectsWayland(t *testing.T) { + // When WAYLAND_DISPLAY is set, Open takes the Wayland path. Pointing it + // at a nonexistent socket makes the dial fail (proving selection). + t.Setenv("WAYLAND_DISPLAY", "gw-nonexistent-wl-0") + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + if _, err := Open(Config{}); err == nil { + t.Fatal("Open with a dead Wayland socket should error") + } +} + +func TestWaylandSocketPath(t *testing.T) { + // An absolute name is used verbatim. + if p, err := waylandSocketPath("/run/user/1000/wayland-0"); err != nil || p != "/run/user/1000/wayland-0" { + t.Fatalf("absolute path = %q err=%v", p, err) + } + // A bare name is joined onto XDG_RUNTIME_DIR. + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + if p, err := waylandSocketPath("wayland-1"); err != nil || p != "/run/user/1000/wayland-1" { + t.Fatalf("joined path = %q err=%v", p, err) + } + // A bare name with no XDG_RUNTIME_DIR errors. + t.Setenv("XDG_RUNTIME_DIR", "") + if _, err := waylandSocketPath("wayland-0"); err == nil { + t.Fatal("bare name with no XDG_RUNTIME_DIR should error") + } +} + +func TestOpenWaylandNoRuntimeDir(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("XDG_RUNTIME_DIR", "") + if _, err := Open(Config{}); err == nil { + t.Fatal("Open with WAYLAND_DISPLAY but no XDG_RUNTIME_DIR should error") + } +} + func TestDialDisplayRemote(t *testing.T) { if _, err := dialDisplay(display{host: "remote", number: "0"}); err == nil { t.Fatal("remote dial should be rejected") diff --git a/open_other.go b/open_other.go index 6942c06..6fdc4ff 100644 --- a/open_other.go +++ b/open_other.go @@ -6,12 +6,13 @@ package window -// Open is unavailable off Linux: there is no X11 server to dial, so it -// returns ErrUnsupported. The window-construction, presentation and -// event-translation logic remains compiled and unit-tested on every -// platform via the transport-agnostic internal/x11 connection; only this -// environment-driven entry point is gated, keeping cross-builds green. -func Open(cfg Config) (*Window, error) { +// Open is unavailable off Linux: there is no X11 server or Wayland +// compositor socket to dial, so it returns ErrUnsupported. The +// window-construction, presentation and event-translation logic of both +// backends remains compiled and unit-tested on every platform via the +// transport-agnostic internal/x11 and internal/wayland connections; only +// this environment-driven entry point is gated, keeping cross-builds green. +func Open(cfg Config) (Backend, error) { _ = cfg return nil, ErrUnsupported } diff --git a/wayland_integration_test.go b/wayland_integration_test.go new file mode 100644 index 0000000..1d6144b --- /dev/null +++ b/wayland_integration_test.go @@ -0,0 +1,116 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +//go:build integration && linux + +// This is the live Wayland proof. It runs only under -tags=integration with +// WINDOW_WAYLAND_INTEGRATION set and a reachable Wayland compositor (a +// headless wlroots compositor — sway — in CI). It opens a real toplevel via +// the sovereign Wayland backend, presents a known four-quadrant colour +// pattern through a wl_shm buffer, captures the compositor output with grim +// and asserts the sampled pixels. Input injection is attempted with wtype +// (virtual-keyboard); when the headless seat exposes no keyboard the input +// assertion is skipped honestly rather than claimed — the input path is +// proven deterministically by the in-process fake-compositor test. +// +// patternRoot, requireTool, mustRun, decodePNG, assertPixel and abs are +// shared with the X11 live test (same package + build tag). +package window + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-widgets/toolkit" +) + +func TestLiveWayland(t *testing.T) { + if os.Getenv("WINDOW_WAYLAND_INTEGRATION") == "" { + t.Skip("set WINDOW_WAYLAND_INTEGRATION=1 (under a Wayland compositor) to enable") + } + if os.Getenv("WAYLAND_DISPLAY") == "" { + t.Fatal("WAYLAND_DISPLAY is not set") + } + requireTool(t, "grim") + + title := fmt.Sprintf("gwwl-live-%d", os.Getpid()) + b, err := Open(Config{Title: title, Class: "gwwltest", Width: 200, Height: 160}) + if err != nil { + t.Fatalf("Open: %v", err) + } + if _, ok := b.(*wlWindow); !ok { + t.Fatalf("Open selected %T, want the Wayland backend", b) + } + root := &patternRoot{} + done := make(chan error, 1) + go func() { done <- b.Run(root) }() + + // Let the compositor map + configure the toplevel and the client present. + time.Sleep(1500 * time.Millisecond) + + // --- Capture and assert the presented pattern. --------------------------- + dir := t.TempDir() + capture := filepath.Join(dir, "capture.png") + mustRun(t, "grim", capture) + img := decodePNG(t, capture) + ib := img.Bounds() + W, H := ib.Dx(), ib.Dy() + if W < 8 || H < 8 { + t.Fatalf("captured image too small: %dx%d", W, H) + } + // The single toplevel fills the headless output; sample each quadrant. + assertPixel(t, img, W/4, H/4, 255, 0, 0, "top-left(red)") + assertPixel(t, img, 3*W/4, H/4, 0, 255, 0, "top-right(green)") + assertPixel(t, img, W/4, 3*H/4, 0, 0, 255, "bottom-left(blue)") + assertPixel(t, img, 3*W/4, 3*H/4, 255, 255, 255, "bottom-right(white)") + + // Persist the capture as a build artifact. + if data, err := os.ReadFile(capture); err == nil { + _ = os.WriteFile("live-wayland-capture.png", data, 0o644) + t.Logf("saved capture to live-wayland-capture.png") + } + + // --- Best-effort live input via the virtual-keyboard protocol. ----------- + // wtype injects a key through zwp_virtual_keyboard; it only reaches the + // client if the headless seat exposes a keyboard capability. If it does + // not (common on a device-less headless seat), we do not fail — the key + // path is proven by the fake-compositor unit test. + if _, err := exec.LookPath("wtype"); err != nil { + t.Log("live input: wtype not installed; input proven by the fake-compositor test (pending-on-compositor)") + } else if out, err := exec.Command("wtype", "a").CombinedOutput(); err != nil { + t.Logf("live input: wtype failed (%v: %s); input proven by the fake-compositor test (pending-on-compositor)", err, out) + } else { + gotChar := false + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + for _, ev := range root.snapshot() { + if ev.Kind == toolkit.EventChar && ev.Code == "a" { + gotChar = true + } + } + if gotChar { + break + } + time.Sleep(100 * time.Millisecond) + } + if gotChar { + t.Log("live input: EventChar 'a' dispatched from a real compositor key") + } else { + t.Log("live input: no key delivered (headless seat exposed no keyboard); pending-on-compositor") + } + } + + if err := b.Close(); err != nil { + t.Logf("close: %v", err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Log("run loop did not exit promptly after close") + } +} diff --git a/window.go b/window.go index ce59eb6..e22ffe0 100644 --- a/window.go +++ b/window.go @@ -29,8 +29,28 @@ import ( "github.com/go-widgets/window/internal/x11" ) -// ErrUnsupported is returned by Open on platforms with no X11 backend. -var ErrUnsupported = errors.New("window: X11 backend is only supported on Linux") +// ErrUnsupported is returned by Open on platforms with no windowing +// backend (non-Linux). +var ErrUnsupported = errors.New("window: a native windowing backend is only supported on Linux") + +// Backend is an open, backend-specific window bound to a go-widgets scene. +// Both the X11 (*Window) and the Wayland backend satisfy it, so Open can +// return whichever the environment selects and a go-widgets application is +// backend-agnostic: it just calls Run, Size, String and Close. +type Backend interface { + // Run binds root, performs the initial layout+present, then dispatches + // server/compositor events into the widget tree until the window closes. + Run(root toolkit.Widget) error + // Close releases the window and its connection. + Close() error + // Size returns the current client size in pixels. + Size() (int, int) + // String identifies the window for debugging. + String() string +} + +// Compile-time assurance that the X11 window satisfies Backend. +var _ Backend = (*Window)(nil) // Config parametrises a window. type Config struct { diff --git a/wlfake_test.go b/wlfake_test.go new file mode 100644 index 0000000..9c1f3b6 --- /dev/null +++ b/wlfake_test.go @@ -0,0 +1,321 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +// This is the in-process scripted fake-compositor proof for the Wayland +// backend. A goroutine plays a minimal xdg-shell compositor over one end of +// a socket pair, speaking the sovereign wire format byte for byte, while the +// backend brings up a toplevel and runs its host loop over the other end. +// It deterministically exercises the whole handshake (registry, binds, shm +// formats, seat capabilities, xkb keymap over an fd, xdg configure/ack) and +// the input path (a click and a key press synthesised by the compositor and +// asserted as dispatched toolkit events), with no display server involved — +// the runtime analogue proven live by the CI headless-compositor lane. +package window + +import ( + "encoding/binary" + "net" + "os" + "syscall" + "testing" + "time" + + "github.com/go-widgets/toolkit" + "github.com/go-widgets/window/internal/wayland" +) + +var no = binary.NativeEndian + +// --- server-side wire helpers --------------------------------------------- + +type srvConn struct { + c *net.UnixConn + rbuf []byte + fds []int +} + +func (s *srvConn) read() (obj uint32, op uint16, body []byte, err error) { + for { + if len(s.rbuf) >= 8 { + size := int(no.Uint32(s.rbuf[4:8]) >> 16) + if size >= 8 && len(s.rbuf) >= size { + msg := s.rbuf[:size] + obj = no.Uint32(msg[0:4]) + op = uint16(no.Uint32(msg[4:8]) & 0xffff) + body = append([]byte(nil), msg[8:size]...) + s.rbuf = s.rbuf[size:] + return obj, op, body, nil + } + } + data := make([]byte, 4096) + oob := make([]byte, 4096) + n, oobn, _, _, e := s.c.ReadMsgUnix(data, oob) + if e != nil { + return 0, 0, nil, e + } + s.rbuf = append(s.rbuf, data[:n]...) + if oobn > 0 { + scms, _ := syscall.ParseSocketControlMessage(oob[:oobn]) + for i := range scms { + if fds, e := syscall.ParseUnixRights(&scms[i]); e == nil { + s.fds = append(s.fds, fds...) + } + } + } + } +} + +func (s *srvConn) popFD() int { + if len(s.fds) == 0 { + return -1 + } + fd := s.fds[0] + s.fds = s.fds[1:] + return fd +} + +func (s *srvConn) send(obj uint32, op uint16, body []byte, fds ...int) error { + m := make([]byte, 8+len(body)) + no.PutUint32(m[0:4], obj) + no.PutUint32(m[4:8], uint32(op)|uint32(8+len(body))<<16) + copy(m[8:], body) + var oob []byte + if len(fds) > 0 { + oob = syscall.UnixRights(fds...) + } + _, _, err := s.c.WriteMsgUnix(m, oob, nil) + return err +} + +func eU32(vs ...uint32) []byte { + b := make([]byte, 4*len(vs)) + for i, v := range vs { + no.PutUint32(b[i*4:], v) + } + return b +} + +func eStr(s string) []byte { + n := len(s) + 1 + b := make([]byte, 4+(n+3)&^3) + no.PutUint32(b[0:4], uint32(n)) + copy(b[4:], s) + return b +} + +func eArr(a []byte) []byte { + b := make([]byte, 4+(len(a)+3)&^3) + no.PutUint32(b[0:4], uint32(len(a))) + copy(b[4:], a) + return b +} + +func eFixed(i int) []byte { return eU32(uint32(int32(i) << 8)) } + +func cat(parts ...[]byte) []byte { + var out []byte + for _, p := range parts { + out = append(out, p...) + } + return out +} + +func decStr(b []byte) (string, []byte) { + n := int(no.Uint32(b[0:4])) + padded := (n + 3) &^ 3 + s := "" + if n > 0 { + s = string(b[4 : 4+n-1]) + } + return s, b[4+padded:] +} + +// keymapFile writes the test xkb keymap (NUL-terminated) to a temp file and +// returns it; the caller passes file.Fd() over SCM_RIGHTS. +func keymapFile(t *testing.T) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "keymap") + if err != nil { + t.Fatalf("keymap temp: %v", err) + } + if _, err := f.WriteString(testKeymap + "\x00"); err != nil { + t.Fatalf("keymap write: %v", err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatalf("keymap seek: %v", err) + } + return f +} + +// fakeCompositor plays the scripted xdg-shell compositor until the client +// disconnects. cfgW/cfgH are the size it suggests via xdg_toplevel.configure. +func fakeCompositor(t *testing.T, sc *srvConn, kmFD int, cfgW, cfgH int) { + var registryID, compID, shmID, wmID, seatID, ptrID, kbID uint32 + var surfID, xdgSurfID, tlID uint32 + serial := uint32(1) + sawAttach := false + configured := false + injected := false + + for { + obj, op, body, err := sc.read() + if err != nil { + return // client closed + } + switch { + case obj == 1 && op == 1: // wl_display.get_registry + registryID = no.Uint32(body[0:4]) + _ = sc.send(registryID, 0, cat(eU32(1), eStr("wl_compositor"), eU32(4))) + _ = sc.send(registryID, 0, cat(eU32(2), eStr("wl_shm"), eU32(1))) + _ = sc.send(registryID, 0, cat(eU32(3), eStr("xdg_wm_base"), eU32(4))) + _ = sc.send(registryID, 0, cat(eU32(4), eStr("wl_seat"), eU32(5))) + case obj == 1 && op == 0: // wl_display.sync + _ = sc.send(no.Uint32(body[0:4]), 0, eU32(0)) // wl_callback.done + case obj == registryID && op == 0: // wl_registry.bind + iface, rest := decStr(body[4:]) + newid := no.Uint32(rest[4:8]) + switch iface { + case "wl_compositor": + compID = newid + case "wl_shm": + shmID = newid + _ = sc.send(shmID, 0, eU32(wayland.ShmFormatARGB8888)) + _ = sc.send(shmID, 0, eU32(wayland.ShmFormatXRGB8888)) + case "xdg_wm_base": + wmID = newid + case "wl_seat": + seatID = newid + _ = sc.send(seatID, 0, eU32(wayland.SeatCapabilityPointer|wayland.SeatCapabilityKeyboard)) + _ = sc.send(seatID, 1, eStr("seat0")) + } + case obj == seatID && op == 0: // wl_seat.get_pointer + ptrID = no.Uint32(body[0:4]) + case obj == seatID && op == 1: // wl_seat.get_keyboard + kbID = no.Uint32(body[0:4]) + _ = sc.send(kbID, 0, cat(eU32(wayland.KeymapFormatXkbV1), eU32(uint32(len(testKeymap)+1))), kmFD) + _ = sc.send(kbID, 5, cat(eU32(25), eU32(600))) // repeat_info + case obj == compID && op == 0: // wl_compositor.create_surface + surfID = no.Uint32(body[0:4]) + case obj == wmID && op == 2: // xdg_wm_base.get_xdg_surface + xdgSurfID = no.Uint32(body[0:4]) + case obj == xdgSurfID && op == 1: // xdg_surface.get_toplevel + tlID = no.Uint32(body[0:4]) + case obj == shmID && op == 0: // wl_shm.create_pool (fd passed) + if fd := sc.popFD(); fd >= 0 { + _ = syscall.Close(fd) + } + case obj == surfID && op == 1: // wl_surface.attach + sawAttach = true + case obj == surfID && op == 6: // wl_surface.commit + switch { + case !configured: // role commit -> first configure + _ = sc.send(tlID, 0, cat(eU32(uint32(cfgW)), eU32(uint32(cfgH)), eArr(nil))) + _ = sc.send(xdgSurfID, 0, eU32(serial)) + serial++ + configured = true + case sawAttach && !injected: // first present -> synthesise input, then close + injected = true + _ = sc.send(ptrID, 0, cat(eU32(serial), eU32(surfID), eFixed(30), eFixed(40))) + serial++ + _ = sc.send(ptrID, 3, cat(eU32(serial), eU32(0), eU32(wayland.BtnLeft), eU32(wayland.StatePressed))) + serial++ + _ = sc.send(kbID, 3, cat(eU32(serial), eU32(0), eU32(evA), eU32(wayland.StatePressed))) + serial++ + _ = sc.send(tlID, 1, nil) // xdg_toplevel.close + } + sawAttach = false + } + } +} + +func TestWaylandBringupAndRun(t *testing.T) { + cli, srv := socketPairWin(t) + defer srv.Close() + + km := keymapFile(t) + defer km.Close() + + sc := &srvConn{c: srv} + go fakeCompositor(t, sc, int(km.Fd()), 200, 160) + + conn := wayland.New(cli) + w, err := newWaylandWindow(conn, Config{Title: "wl-test", Class: "wltest", Width: 100, Height: 80}) + if err != nil { + t.Fatalf("newWaylandWindow: %v", err) + } + // The compositor-suggested size is applied by the run loop, not at + // bring-up, so it still reads the initial size here. + if gw, gh := w.Size(); gw != 100 || gh != 80 { + t.Fatalf("size at bring-up = %dx%d, want 100x80", gw, gh) + } + if w.String() == "" { + t.Fatal("String empty") + } + + root := &recWidget{} + done := make(chan error, 1) + go func() { done <- w.Run(root) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not exit after compositor close") + } + + // The run loop applied the compositor's 200x160 configure. + if gw, gh := w.Size(); gw != 200 || gh != 160 { + t.Fatalf("size after run = %dx%d, want 200x160", gw, gh) + } + + var gotClick, gotChar bool + var cx, cy int + for _, ev := range root.events { + switch { + case ev.Kind == toolkit.EventClick: + gotClick, cx, cy = true, ev.X, ev.Y + case ev.Kind == toolkit.EventChar && ev.Code == "a": + gotChar = true + } + } + if !gotClick { + t.Errorf("no EventClick dispatched; events=%+v", root.events) + } else if cx != 30 || cy != 40 { + t.Errorf("click at (%d,%d), want (30,40)", cx, cy) + } + if !gotChar { + t.Errorf("no EventChar 'a' dispatched; events=%+v", root.events) + } + if root.drawn == 0 { + t.Error("root never drawn") + } + if err := w.Close(); err != nil { + t.Errorf("Close: %v", err) + } + if err := w.Close(); err != nil { // idempotent + t.Errorf("second Close: %v", err) + } +} + +// socketPairWin returns two connected *net.UnixConn endpoints (the window +// package's local copy of the socketpair helper). +func socketPairWin(t *testing.T) (*net.UnixConn, *net.UnixConn) { + t.Helper() + fds, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + mk := func(fd int) *net.UnixConn { + f := os.NewFile(uintptr(fd), "sp") + c, err := net.FileConn(f) + _ = f.Close() + if err != nil { + t.Fatalf("FileConn: %v", err) + } + return c.(*net.UnixConn) + } + return mk(fds[0]), mk(fds[1]) +} diff --git a/wlwindow.go b/wlwindow.go new file mode 100644 index 0000000..25dfd1b --- /dev/null +++ b/wlwindow.go @@ -0,0 +1,511 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package window + +import ( + "fmt" + + "github.com/go-widgets/painter" + "github.com/go-widgets/toolkit" + "github.com/go-widgets/window/internal/wayland" +) + +// wlWindow is the Wayland-backed window. It owns the RGBA framebuffer, +// presents it through a double-buffered wl_shm pool and drives the toolkit +// widget tree from wl_pointer / wl_keyboard events routed through the +// sovereign internal/wayland protocol machine. It satisfies Backend, so a +// go-widgets application runs on it exactly as on the X11 backend. +// +// The connection setup, buffer management and event translation are all +// transport-agnostic (the connection may be a real compositor socket or an +// in-process fake compositor), which is what makes the whole Wayland path +// testable without a display server. +type wlWindow struct { + conn *wayland.Conn + surface *wayland.Surface + xdgSurf *wayland.XdgSurface + toplevel *wayland.XdgToplevel + shm *wayland.Shm + pointer *wayland.Pointer + keyboard *wayland.Keyboard + + pool *wayland.ShmPool + buffers [2]*wayland.Buffer + poolData []byte + poolCap int + stride int + bufW int + bufH int + cur int + + w, h int + buf []byte // RGBA framebuffer, 4*w*h bytes + theme *toolkit.Theme + root toolkit.Widget + + ptrX, ptrY int + buttons int // bitmask of pressed pointer buttons (for drag detection) + + pending []toolkit.Event + repaint bool + quit bool + closed bool + + needResize bool + pendingW, pendingH int + configured bool + needAck bool + ackSerial uint32 +} + +// Required Wayland globals for a shell window. +const ( + ifaceCompositor = "wl_compositor" + ifaceShm = "wl_shm" + ifaceXdgWmBase = "xdg_wm_base" +) + +// newWaylandWindow performs the full xdg-shell bring-up over conn: enumerate +// globals, bind the compositor/shm/xdg_wm_base (and the seat, if any), +// create the surface + xdg toplevel, set its identity and wait for the first +// configure. It is transport-agnostic, so a fake compositor drives it in +// tests. +func newWaylandWindow(conn *wayland.Conn, cfg Config) (*wlWindow, error) { + if cfg.Width <= 0 { + cfg.Width = 640 + } + if cfg.Height <= 0 { + cfg.Height = 480 + } + theme := cfg.Theme + if theme == nil { + theme = toolkit.DefaultDark() + } + + w := &wlWindow{ + conn: conn, + w: cfg.Width, + h: cfg.Height, + theme: theme, + buf: make([]byte, 4*cfg.Width*cfg.Height), + } + + reg, err := conn.Display().GetRegistry() + if err != nil { + return nil, err + } + if err := conn.Roundtrip(); err != nil { + return nil, err + } + + comp, err := reg.Compositor() + if err != nil { + return nil, err + } + if w.shm, err = reg.Shm(); err != nil { + return nil, err + } + wm, err := reg.XdgWmBase() + if err != nil { + return nil, err + } + + var seat *wayland.Seat + if _, ok := reg.Find("wl_seat"); ok { + if seat, err = reg.Seat(); err != nil { + return nil, err + } + } + + // A second round-trip delivers the shm formats and seat capabilities. + if err := conn.Roundtrip(); err != nil { + return nil, err + } + if err := w.bindInput(seat); err != nil { + return nil, err + } + + if w.surface, err = comp.CreateSurface(); err != nil { + return nil, err + } + if w.xdgSurf, err = wm.GetXdgSurface(w.surface); err != nil { + return nil, err + } + if w.toplevel, err = w.xdgSurf.GetToplevel(); err != nil { + return nil, err + } + w.wireShell(cfg) + + if err := w.toplevel.SetTitle(cfg.Title); err != nil { + return nil, err + } + appID := cfg.Instance + if appID == "" { + appID = cfg.Class + } + if appID == "" { + appID = cfg.Title + } + if err := w.toplevel.SetAppID(appID); err != nil { + return nil, err + } + // Commit the role with no buffer to elicit the initial configure, then + // wait for it (the ack happens on the next dispatch in the run loop, but + // we perform the pending ack here too so the first present is valid). + if err := w.surface.Commit(); err != nil { + return nil, err + } + if err := conn.Roundtrip(); err != nil { + return nil, err + } + if err := w.flushAck(); err != nil { + return nil, err + } + return w, nil +} + +// bindInput obtains the pointer and keyboard from the seat (when present and +// advertised) and wires their event callbacks. +func (w *wlWindow) bindInput(seat *wayland.Seat) error { + if seat == nil { + return nil + } + if seat.HasPointer() { + p, err := seat.GetPointer() + if err != nil { + return err + } + w.pointer = p + w.wirePointer() + } + if seat.HasKeyboard() { + k, err := seat.GetKeyboard() + if err != nil { + return err + } + w.keyboard = k + w.wireKeyboard() + } + return nil +} + +// wireShell installs the xdg_surface / xdg_toplevel configure + close +// callbacks. Configure resizes on a nonzero suggested size; the ack is +// deferred to the run loop via flushAck. +func (w *wlWindow) wireShell(_ Config) { + w.xdgSurf.OnConfigure = func(serial uint32) { + w.ackSerial = serial + w.needAck = true + w.configured = true + w.repaint = true + } + w.toplevel.OnConfigure = func(cw, ch int, _ []byte) { + if cw > 0 && ch > 0 && (cw != w.w || ch != w.h) { + w.pendingW, w.pendingH = cw, ch + w.needResize = true + } + } + w.toplevel.OnClose = func() { w.quit = true } +} + +// wirePointer installs the pointer event callbacks that translate motion, +// buttons and axis into queued toolkit events. +func (w *wlWindow) wirePointer() { + w.pointer.OnEnter = func(x, y wayland.Fixed) { w.ptrX, w.ptrY = x.Int(), y.Int() } + w.pointer.OnLeave = func() {} + w.pointer.OnMotion = func(x, y wayland.Fixed) { + w.ptrX, w.ptrY = x.Int(), y.Int() + w.queue(w.translateMotion(w.mods())) + } + w.pointer.OnButton = func(button uint32, pressed bool) { + s, c := w.mods() + w.queue(w.translateButton(button, pressed, s, c)) + } + w.pointer.OnAxis = func(axis uint32, value wayland.Fixed) { + s, c := w.mods() + w.queue(w.translateAxis(axis, value, s, c)) + } +} + +// wireKeyboard installs the key callback that translates a keycode into +// key/char toolkit events via the parsed xkb keymap and modifier state. +func (w *wlWindow) wireKeyboard() { + w.keyboard.OnKey = func(evdev uint32, pressed bool) { + w.queue(translateKey(w.keyboard.Keymap(), evdev, pressed, w.keyboard.Shift(), w.keyboard.Ctrl())) + } + w.keyboard.OnModifiers = func() {} +} + +// mods returns the current Shift/Ctrl modifier state (false if no keyboard). +func (w *wlWindow) mods() (shift, ctrl bool) { + if w.keyboard == nil { + return false, false + } + return w.keyboard.Shift(), w.keyboard.Ctrl() +} + +// queue appends translated events to the pending batch and marks a repaint. +func (w *wlWindow) queue(evs []toolkit.Event) { + if len(evs) == 0 { + return + } + w.pending = append(w.pending, evs...) + w.repaint = true +} + +// --- event → toolkit translation (pure) ----------------------------------- + +// translateKey maps an evdev keycode at the current shift level to toolkit +// key/char events. A modifier key yields nothing; a named key yields a +// single KeyDown/KeyUp carrying the name; a printable key yields KeyDown+Char +// on press and KeyUp on release. An unmapped key yields nothing. +func translateKey(km *wayland.Keymap, evdev uint32, pressed, shift, ctrl bool) []toolkit.Event { + key := km.Lookup(evdev, shift) + if key.IsModifier { + return nil + } + if key.Name != "" { + kind := toolkit.EventKeyDown + if !pressed { + kind = toolkit.EventKeyUp + } + return []toolkit.Event{{Kind: kind, Code: key.Name, Ctrl: ctrl, Shift: shift}} + } + if key.HasRune { + s := string(key.Rune) + if pressed { + return []toolkit.Event{ + {Kind: toolkit.EventKeyDown, Code: s, Ctrl: ctrl, Shift: shift}, + {Kind: toolkit.EventChar, Code: s, Ctrl: ctrl, Shift: shift}, + } + } + return []toolkit.Event{{Kind: toolkit.EventKeyUp, Code: s, Ctrl: ctrl, Shift: shift}} + } + return nil +} + +// buttonBit maps a Linux button code to a drag-tracking bit; 0 for buttons +// that carry no toolkit meaning. +func buttonBit(button uint32) int { + switch button { + case wayland.BtnLeft: + return 1 + case wayland.BtnMiddle: + return 2 + case wayland.BtnRight: + return 4 + default: + return 0 + } +} + +// translateButton maps a pointer button press/release to a click (press) or +// mouse-up (release) at the last-known pointer position, updating the held +// -button mask used for drag detection. +func (w *wlWindow) translateButton(button uint32, pressed, shift, ctrl bool) []toolkit.Event { + bit := buttonBit(button) + if bit == 0 { + return nil + } + if pressed { + w.buttons |= bit + return []toolkit.Event{{Kind: toolkit.EventClick, X: w.ptrX, Y: w.ptrY, Ctrl: ctrl, Shift: shift}} + } + w.buttons &^= bit + return []toolkit.Event{{Kind: toolkit.EventMouseUp, X: w.ptrX, Y: w.ptrY, Ctrl: ctrl, Shift: shift}} +} + +// translateMotion maps a pointer motion to a drag (a button held) or a plain +// hover move (no button) at the current pointer position. +func (w *wlWindow) translateMotion(shift, ctrl bool) []toolkit.Event { + kind := toolkit.EventMouseMove + if w.buttons != 0 { + kind = toolkit.EventMouseDrag + } + return []toolkit.Event{{Kind: kind, X: w.ptrX, Y: w.ptrY, Ctrl: ctrl, Shift: shift}} +} + +// translateAxis maps a vertical scroll axis tick to an EventScroll (one row +// per tick, sign following the scroll direction). Horizontal scroll carries +// no toolkit meaning and is dropped. +func (w *wlWindow) translateAxis(axis uint32, value wayland.Fixed, shift, ctrl bool) []toolkit.Event { + if axis != wayland.AxisVerticalScroll || value == 0 { + return nil + } + delta := 1 + if value.Float() < 0 { + delta = -1 + } + return []toolkit.Event{{Kind: toolkit.EventScroll, X: w.ptrX, Y: w.ptrY, Delta: delta, Ctrl: ctrl, Shift: shift}} +} + +// --- present -------------------------------------------------------------- + +// draw repaints the whole framebuffer: background fill then the root widget +// laid out to fill the client area. +func (w *wlWindow) draw() { + p := painter.NewPixelPainter(w.buf, w.w, w.h) + full := toolkit.Rect{X: 0, Y: 0, W: w.w, H: w.h} + p.FillRect(full, w.theme.Background) + if w.root != nil { + w.root.SetBounds(full) + w.root.Draw(p, w.theme) + } +} + +// ensureBuffers (re)creates the double-buffered wl_shm pool whenever the +// surface size changes, carving two ARGB8888 buffers from it. +func (w *wlWindow) ensureBuffers() error { + if w.pool != nil && w.bufW == w.w && w.bufH == w.h { + return nil + } + if w.pool != nil { + for i := range w.buffers { + if w.buffers[i] != nil { + _ = w.buffers[i].Destroy() + w.buffers[i] = nil + } + } + _ = w.pool.Destroy() + w.pool = nil + } + stride := w.w * 4 + size := stride * w.h * 2 + pool, err := w.shm.CreatePool(size) + if err != nil { + return err + } + for i := 0; i < 2; i++ { + buf, err := pool.CreateBuffer(i*stride*w.h, w.w, w.h, stride, wayland.ShmFormatARGB8888) + if err != nil { + return err + } + w.buffers[i] = buf + } + w.pool = pool + w.poolData = pool.Data() + w.poolCap = size + w.stride = stride + w.bufW, w.bufH = w.w, w.h + w.cur = 0 + return nil +} + +// present packs the RGBA framebuffer into a free pool buffer as ARGB8888, +// attaches it, marks whole-surface buffer damage, requests a frame-throttle +// callback and commits. +func (w *wlWindow) present() error { + if !w.configured { + return nil + } + if err := w.ensureBuffers(); err != nil { + return err + } + idx := w.cur + if !w.buffers[idx].Released() && w.buffers[1-idx].Released() { + idx = 1 - idx + } + off := idx * w.stride * w.h + wayland.PackARGB8888(w.poolData[off:], w.stride, w.buf, w.w*4, w.w, w.h) + if err := w.surface.Attach(w.buffers[idx], 0, 0); err != nil { + return err + } + if err := w.surface.DamageBuffer(0, 0, w.w, w.h); err != nil { + return err + } + if _, err := w.surface.Frame(); err != nil { + return err + } + if err := w.surface.Commit(); err != nil { + return err + } + w.cur = 1 - idx + return nil +} + +// flushAck acknowledges a pending configure serial, if any. +func (w *wlWindow) flushAck() error { + if !w.needAck { + return nil + } + w.needAck = false + return w.xdgSurf.AckConfigure(w.ackSerial) +} + +// applyResize grows/shrinks the framebuffer to the pending size. +func (w *wlWindow) applyResize() { + if w.pendingW <= 0 || w.pendingH <= 0 { + w.needResize = false + return + } + w.w, w.h = w.pendingW, w.pendingH + w.buf = make([]byte, 4*w.w*w.h) + w.needResize = false + w.repaint = true +} + +// --- Backend -------------------------------------------------------------- + +// Run binds root, performs the initial layout+present, then dispatches +// compositor events into the toolkit until the window is closed +// (xdg_toplevel.close) or the connection ends. It is the Wayland analogue of +// the X11 host loop and the wasm compositor host loop. +func (w *wlWindow) Run(root toolkit.Widget) error { + w.root = root + w.draw() + if err := w.present(); err != nil { + return err + } + for !w.quit { + if err := w.conn.Dispatch(); err != nil { + return err + } + if err := w.flushAck(); err != nil { + return err + } + if w.needResize { + w.applyResize() + } + if w.root != nil { + for _, ev := range w.pending { + w.root.OnEvent(ev) + } + } + if w.repaint || len(w.pending) > 0 { + w.draw() + if err := w.present(); err != nil { + return err + } + } + w.pending = w.pending[:0] + w.repaint = false + } + return nil +} + +// Size returns the current client size in pixels. +func (w *wlWindow) Size() (int, int) { return w.w, w.h } + +// Close destroys the pool and closes the connection (idempotent). +func (w *wlWindow) Close() error { + if w.closed { + return nil + } + w.closed = true + if w.pool != nil { + _ = w.pool.Destroy() + w.pool = nil + } + return w.conn.Close() +} + +// String identifies the window for debugging. +func (w *wlWindow) String() string { + var sid uint32 + if w.surface != nil { + sid = w.surface.ID() + } + return fmt.Sprintf("wayland-window(%dx%d surface=%d)", w.w, w.h, sid) +} diff --git a/wlwindow_test.go b/wlwindow_test.go new file mode 100644 index 0000000..ceaf467 --- /dev/null +++ b/wlwindow_test.go @@ -0,0 +1,208 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package window + +import ( + "testing" + + "github.com/go-widgets/toolkit" + "github.com/go-widgets/window/internal/wayland" +) + +// testKeymap is a tiny xkb keymap covering the keys the translation tests +// exercise: a/A, Return, Shift_L (modifier), space. +const testKeymap = ` +xkb_keymap { + xkb_keycodes "k" { + = 38; + = 36; + = 50; + = 65; + }; + xkb_symbols "s" { + key { [ a, A ] }; + key { [ Return ] }; + key { [ Shift_L ] }; + key { [ space ] }; + }; +}; +` + +// evdev codes are the xkb keycodes minus the constant offset of 8. +const ( + evA = 38 - 8 + evRtrn = 36 - 8 + evShift = 50 - 8 + evSpace = 65 - 8 +) + +func TestTranslateKey(t *testing.T) { + km := wayland.ParseKeymap(testKeymap) + + // Printable press -> KeyDown + Char; release -> KeyUp. + evs := translateKey(km, evA, true, false, false) + if len(evs) != 2 || evs[0].Kind != toolkit.EventKeyDown || evs[0].Code != "a" || + evs[1].Kind != toolkit.EventChar || evs[1].Code != "a" { + t.Fatalf("press a = %+v", evs) + } + up := translateKey(km, evA, false, false, false) + if len(up) != 1 || up[0].Kind != toolkit.EventKeyUp || up[0].Code != "a" { + t.Fatalf("release a = %+v", up) + } + // Shift selects level 1 ('A') and marks the event Shift. + sh := translateKey(km, evA, true, true, false) + if sh[1].Code != "A" || !sh[1].Shift { + t.Fatalf("shifted a = %+v", sh) + } + // Ctrl passes through. + if c := translateKey(km, evA, true, false, true); !c[0].Ctrl { + t.Fatalf("ctrl a = %+v", c) + } + // Named key: single KeyDown/KeyUp carrying the toolkit name. + nd := translateKey(km, evRtrn, true, false, false) + if len(nd) != 1 || nd[0].Kind != toolkit.EventKeyDown || nd[0].Code != "Enter" { + t.Fatalf("Return down = %+v", nd) + } + nu := translateKey(km, evRtrn, false, false, false) + if nu[0].Kind != toolkit.EventKeyUp || nu[0].Code != "Enter" { + t.Fatalf("Return up = %+v", nu) + } + // space -> ' ' rune. + sp := translateKey(km, evSpace, true, false, false) + if sp[1].Kind != toolkit.EventChar || sp[1].Code != " " { + t.Fatalf("space = %+v", sp) + } + // Modifier key delivers nothing. + if m := translateKey(km, evShift, true, false, false); m != nil { + t.Fatalf("Shift_L should deliver nothing: %+v", m) + } + // Unmapped keycode delivers nothing. + if u := translateKey(km, 250, true, false, false); u != nil { + t.Fatalf("unmapped key should deliver nothing: %+v", u) + } +} + +func TestTranslateButton(t *testing.T) { + w := &wlWindow{ptrX: 33, ptrY: 44} + // Left press -> EventClick + sets the drag bit. + press := w.translateButton(wayland.BtnLeft, true, false, false) + if len(press) != 1 || press[0].Kind != toolkit.EventClick || press[0].X != 33 || press[0].Y != 44 { + t.Fatalf("left press = %+v", press) + } + if w.buttons == 0 { + t.Fatal("press should set the drag bit") + } + // Release -> EventMouseUp + clears the bit; modifiers pass through. + rel := w.translateButton(wayland.BtnLeft, false, true, true) + if rel[0].Kind != toolkit.EventMouseUp || !rel[0].Ctrl || !rel[0].Shift { + t.Fatalf("left release = %+v", rel) + } + if w.buttons != 0 { + t.Fatal("release should clear the drag bit") + } + // Right/middle also map; an unknown button is dropped. + if r := w.translateButton(wayland.BtnRight, true, false, false); r[0].Kind != toolkit.EventClick { + t.Fatalf("right press = %+v", r) + } + if m := w.translateButton(wayland.BtnMiddle, true, false, false); m[0].Kind != toolkit.EventClick { + t.Fatalf("middle press = %+v", m) + } + if u := w.translateButton(0x999, true, false, false); u != nil { + t.Fatalf("unknown button should be dropped: %+v", u) + } +} + +func TestTranslateMotion(t *testing.T) { + w := &wlWindow{ptrX: 5, ptrY: 6} + // No button held -> hover move. + if mv := w.translateMotion(false, false); mv[0].Kind != toolkit.EventMouseMove || mv[0].X != 5 { + t.Fatalf("move = %+v", mv) + } + // Button held -> drag; modifiers pass through. + w.buttons = 1 + if dr := w.translateMotion(true, false); dr[0].Kind != toolkit.EventMouseDrag || !dr[0].Shift { + t.Fatalf("drag = %+v", dr) + } +} + +func TestTranslateAxis(t *testing.T) { + w := &wlWindow{ptrX: 1, ptrY: 2} + down := w.translateAxis(wayland.AxisVerticalScroll, wayland.FixedFromInt(10), false, false) + if len(down) != 1 || down[0].Kind != toolkit.EventScroll || down[0].Delta != 1 { + t.Fatalf("scroll down = %+v", down) + } + up := w.translateAxis(wayland.AxisVerticalScroll, wayland.FixedFromInt(-5), false, false) + if up[0].Delta != -1 { + t.Fatalf("scroll up = %+v", up) + } + // Zero value and horizontal axis produce nothing. + if z := w.translateAxis(wayland.AxisVerticalScroll, 0, false, false); z != nil { + t.Fatalf("zero axis = %+v", z) + } + if h := w.translateAxis(wayland.AxisHorizontalScroll, wayland.FixedFromInt(3), false, false); h != nil { + t.Fatalf("horizontal axis = %+v", h) + } +} + +func TestButtonBit(t *testing.T) { + if buttonBit(wayland.BtnLeft) != 1 || buttonBit(wayland.BtnMiddle) != 2 || + buttonBit(wayland.BtnRight) != 4 || buttonBit(0x999) != 0 { + t.Fatal("buttonBit mapping wrong") + } +} + +func TestWlWindowDrawAndHelpers(t *testing.T) { + root := &recWidget{} + w := &wlWindow{w: 20, h: 10, buf: make([]byte, 4*20*10), theme: toolkit.DefaultDark(), root: root} + w.draw() + if root.drawn == 0 { + t.Fatal("root should be drawn") + } + if gw, gh := w.Size(); gw != 20 || gh != 10 { + t.Fatalf("Size = %dx%d", gw, gh) + } + if w.String() == "" { + t.Fatal("String empty") + } + // mods with no keyboard is false/false. + if s, c := w.mods(); s || c { + t.Fatal("mods with no keyboard should be false") + } + // queue of nothing is a no-op; queue of something marks repaint. + w.queue(nil) + if w.repaint { + t.Fatal("empty queue should not repaint") + } + w.queue([]toolkit.Event{{Kind: toolkit.EventClick}}) + if !w.repaint || len(w.pending) != 1 { + t.Fatal("queue should append and mark repaint") + } + // draw with no root is fine. + (&wlWindow{w: 4, h: 4, buf: make([]byte, 4*4*4), theme: toolkit.DefaultDark()}).draw() +} + +func TestWlPresentBeforeConfigure(t *testing.T) { + // present is a no-op until the first xdg configure arrives. + w := &wlWindow{configured: false} + if err := w.present(); err != nil { + t.Fatalf("present before configure should be a no-op: %v", err) + } +} + +func TestWlWindowApplyResize(t *testing.T) { + w := &wlWindow{w: 10, h: 10, buf: make([]byte, 4*10*10)} + w.pendingW, w.pendingH, w.needResize = 30, 20, true + w.applyResize() + if w.w != 30 || w.h != 20 || len(w.buf) != 4*30*20 || !w.repaint || w.needResize { + t.Fatalf("resize state w=%d h=%d buf=%d", w.w, w.h, len(w.buf)) + } + // A zero pending size is rejected and clears the flag. + w.pendingW, w.pendingH, w.needResize = 0, 0, true + w.applyResize() + if w.needResize { + t.Fatal("zero resize should clear the flag") + } +} +