detached 54d04f6

feat(server): prove libwayland-server bindings without cgo

Bind non-varargs libwayland-server entry points through purego, build wl_interface tables in mmap memory, and route requests through one shared dispatcher. Feasibility tests cover wayland-info, request/event roundtrip, and resource cleanup on disconnect.
commit
54d04f6111e760cd53c6cd28a9e8e8273f8c6ada
author
brice · · 2026-09-23 09:52 UTC
parent
none (root commit)
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +775 -0
A .github/workflows/ci.yml +17 -0
@@ -0,0 +1,17 @@
1+name: ci
2+
3+on:
4+ push:
5+ pull_request:
6+
7+jobs:
8+ test:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - uses: actions/checkout@v4
12+ - uses: actions/setup-go@v5
13+ with:
14+ go-version: '1.27'
15+ - run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
16+ - run: make check
17+ - run: make race
@@ -0,0 +1,17 @@
1+name: ci
2+
3+on:
4+ push:
5+ pull_request:
6+
7+jobs:
8+ test:
9+ runs-on: ubuntu-latest
10+ steps:
11+ - uses: actions/checkout@v4
12+ - uses: actions/setup-go@v5
13+ with:
14+ go-version: '1.27'
15+ - run: sudo apt-get install -y libwayland-server0 libwayland-client0 wayland-utils
16+ - run: make check
17+ - run: make race
A .gitignore +5 -0
@@ -0,0 +1,5 @@
1+*.test
2+*.out
3+coverage.txt
4+.DS_Store
5+.worktrees/
@@ -0,0 +1,5 @@
1+*.test
2+*.out
3+coverage.txt
4+.DS_Store
5+.worktrees/
A AGENTS.md +8 -0
@@ -0,0 +1,8 @@
1+# Repository rules
2+
3+- Go 1.27 only.
4+- Library code must build with `CGO_ENABLED=0`.
5+- Use only non-varargs libwayland entry points (no `va_list`).
6+- Make all libwayland calls on one OS-locked goroutine.
7+- C-visible memory must never be Go-GC-managed.
8+- Use signed conventional commits: `type(scope): description`.
@@ -0,0 +1,8 @@
1+# Repository rules
2+
3+- Go 1.27 only.
4+- Library code must build with `CGO_ENABLED=0`.
5+- Use only non-varargs libwayland entry points (no `va_list`).
6+- Make all libwayland calls on one OS-locked goroutine.
7+- C-visible memory must never be Go-GC-managed.
8+- Use signed conventional commits: `type(scope): description`.
A LICENSE +21 -0
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 bnema
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 bnema
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
A Makefile +13 -0
@@ -0,0 +1,13 @@
1+.PHONY: test vet race check
2+
3+test:
4+ CGO_ENABLED=0 go test ./...
5+
6+vet:
7+ CGO_ENABLED=0 go vet ./...
8+
9+# The race detector needs cgo only for the test binary; library code stays cgo-free.
10+race:
11+ CGO_ENABLED=1 go test -race ./...
12+
13+check: vet test
@@ -0,0 +1,13 @@
1+.PHONY: test vet race check
2+
3+test:
4+ CGO_ENABLED=0 go test ./...
5+
6+vet:
7+ CGO_ENABLED=0 go vet ./...
8+
9+# The race detector needs cgo only for the test binary; library code stays cgo-free.
10+race:
11+ CGO_ENABLED=1 go test -race ./...
12+
13+check: vet test
A README.md +21 -0
@@ -0,0 +1,21 @@
1+# purego-libwayland
2+
3+Go bindings for libwayland without cgo, loaded at runtime through github.com/bnema/purego.
4+
5+## Status
6+
7+Early v0.x. Server side first; client side planned.
8+
9+## Requirements
10+
11+Linux, Go 1.27, and `libwayland-server.so.0` at runtime.
12+
13+## Build
14+
15+```sh
16+CGO_ENABLED=0 go build ./...
17+```
18+
19+## License
20+
21+MIT.
@@ -0,0 +1,21 @@
1+# purego-libwayland
2+
3+Go bindings for libwayland without cgo, loaded at runtime through github.com/bnema/purego.
4+
5+## Status
6+
7+Early v0.x. Server side first; client side planned.
8+
9+## Requirements
10+
11+Linux, Go 1.27, and `libwayland-server.so.0` at runtime.
12+
13+## Build
14+
15+```sh
16+CGO_ENABLED=0 go build ./...
17+```
18+
19+## License
20+
21+MIT.
A go.mod +9 -0
@@ -0,0 +1,9 @@
1+module github.com/bnema/purego-libwayland
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/purego v0.11.0-bnema.4
7+ github.com/bnema/wlturbo v0.2.0
8+ golang.org/x/sys v0.48.0
9+)
@@ -0,0 +1,9 @@
1+module github.com/bnema/purego-libwayland
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/purego v0.11.0-bnema.4
7+ github.com/bnema/wlturbo v0.2.0
8+ golang.org/x/sys v0.48.0
9+)
A go.sum +6 -0 generated file: collapsed
A internal/proto/core.go +55 -0
@@ -0,0 +1,55 @@
1+// Package proto holds hand-written core interfaces for the feasibility
2+// prototype. The generator replaces this package.
3+package proto
4+
5+import (
6+ "sync"
7+
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+var (
12+ Compositor = &server.Interface{Name: "wl_compositor", Version: 1}
13+ Surface = &server.Interface{Name: "wl_surface", Version: 1}
14+ Region = &server.Interface{Name: "wl_region", Version: 1}
15+ Callback = &server.Interface{Name: "wl_callback", Version: 1}
16+)
17+
18+// Opcodes used by the prototype.
19+const (
20+ CompositorCreateSurface = 0
21+ CompositorCreateRegion = 1
22+ SurfaceDestroy = 0
23+ SurfaceFrame = 3
24+ SurfaceCommit = 6
25+ RegionDestroy = 0
26+ CallbackDone = 0
27+)
28+
29+var initOnce = sync.OnceValue(initInterfaces)
30+
31+// Init builds the C tables once per process.
32+func Init() error { return initOnce() }
33+
34+func initInterfaces() error {
35+ Compositor.Requests = []server.Message{
36+ {Name: "create_surface", Signature: "n", Types: []*server.Interface{Surface}},
37+ {Name: "create_region", Signature: "n", Types: []*server.Interface{Region}},
38+ }
39+ Surface.Requests = []server.Message{
40+ {Name: "destroy", Signature: ""},
41+ {Name: "attach", Signature: "?oii", Types: []*server.Interface{nil, nil, nil}},
42+ {Name: "damage", Signature: "iiii"},
43+ {Name: "frame", Signature: "n", Types: []*server.Interface{Callback}},
44+ {Name: "set_opaque_region", Signature: "?o", Types: []*server.Interface{Region}},
45+ {Name: "set_input_region", Signature: "?o", Types: []*server.Interface{Region}},
46+ {Name: "commit", Signature: ""},
47+ }
48+ Region.Requests = []server.Message{
49+ {Name: "destroy", Signature: ""},
50+ {Name: "add", Signature: "iiii"},
51+ {Name: "subtract", Signature: "iiii"},
52+ }
53+ Callback.Events = []server.Message{{Name: "done", Signature: "u"}}
54+ return server.NewInterfaces(Compositor, Surface, Region, Callback)
55+}
@@ -0,0 +1,55 @@
1+// Package proto holds hand-written core interfaces for the feasibility
2+// prototype. The generator replaces this package.
3+package proto
4+
5+import (
6+ "sync"
7+
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+var (
12+ Compositor = &server.Interface{Name: "wl_compositor", Version: 1}
13+ Surface = &server.Interface{Name: "wl_surface", Version: 1}
14+ Region = &server.Interface{Name: "wl_region", Version: 1}
15+ Callback = &server.Interface{Name: "wl_callback", Version: 1}
16+)
17+
18+// Opcodes used by the prototype.
19+const (
20+ CompositorCreateSurface = 0
21+ CompositorCreateRegion = 1
22+ SurfaceDestroy = 0
23+ SurfaceFrame = 3
24+ SurfaceCommit = 6
25+ RegionDestroy = 0
26+ CallbackDone = 0
27+)
28+
29+var initOnce = sync.OnceValue(initInterfaces)
30+
31+// Init builds the C tables once per process.
32+func Init() error { return initOnce() }
33+
34+func initInterfaces() error {
35+ Compositor.Requests = []server.Message{
36+ {Name: "create_surface", Signature: "n", Types: []*server.Interface{Surface}},
37+ {Name: "create_region", Signature: "n", Types: []*server.Interface{Region}},
38+ }
39+ Surface.Requests = []server.Message{
40+ {Name: "destroy", Signature: ""},
41+ {Name: "attach", Signature: "?oii", Types: []*server.Interface{nil, nil, nil}},
42+ {Name: "damage", Signature: "iiii"},
43+ {Name: "frame", Signature: "n", Types: []*server.Interface{Callback}},
44+ {Name: "set_opaque_region", Signature: "?o", Types: []*server.Interface{Region}},
45+ {Name: "set_input_region", Signature: "?o", Types: []*server.Interface{Region}},
46+ {Name: "commit", Signature: ""},
47+ }
48+ Region.Requests = []server.Message{
49+ {Name: "destroy", Signature: ""},
50+ {Name: "add", Signature: "iiii"},
51+ {Name: "subtract", Signature: "iiii"},
52+ }
53+ Callback.Events = []server.Message{{Name: "done", Signature: "u"}}
54+ return server.NewInterfaces(Compositor, Surface, Region, Callback)
55+}
A internal/proto/feasibility_test.go +199 -0
@@ -0,0 +1,199 @@
1+package proto_test
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/purego-libwayland/internal/proto"
12+ "github.com/bnema/purego-libwayland/server"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// startServer runs a display with a wl_compositor global on a private socket.
18+// commits counts wl_surface.commit requests received by Go handlers.
19+func startServer(t *testing.T) (socket string, d *server.Display, commits chan uint32) {
20+ t.Helper()
21+ if err := proto.Init(); err != nil {
22+ t.Fatal(err)
23+ }
24+ d, err := server.NewDisplay()
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ socket = filepath.Join(t.TempDir(), "wayland-test")
29+ // libwayland takes ownership of the listening fd.
30+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: socket}); err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := unix.Listen(fd, 128); err != nil {
38+ t.Fatal(err)
39+ }
40+ if err := d.AddSocketFD(fd); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ commits = make(chan uint32, 16)
45+ var serial uint32
46+ surface := func(r *server.Resource, op uint32, args []server.Arg) {
47+ switch op {
48+ case proto.SurfaceDestroy:
49+ r.Destroy()
50+ case proto.SurfaceFrame:
51+ cb, err := r.Client().CreateResource(proto.Callback, 1, args[0].NewID(), nil)
52+ if err != nil {
53+ t.Error(err)
54+ return
55+ }
56+ serial++
57+ cb.PostEvent(proto.CallbackDone, server.Uint(serial))
58+ cb.Destroy()
59+ case proto.SurfaceCommit:
60+ commits <- r.ID()
61+ }
62+ }
63+ compositor := func(r *server.Resource, op uint32, args []server.Arg) {
64+ iface, h := proto.Surface, server.Handler(surface)
65+ if op == proto.CompositorCreateRegion {
66+ iface, h = proto.Region, func(r *server.Resource, op uint32, _ []server.Arg) {
67+ if op == proto.RegionDestroy {
68+ r.Destroy()
69+ }
70+ }
71+ }
72+ if _, err := r.Client().CreateResource(iface, r.Version(), args[0].NewID(), h); err != nil {
73+ t.Error(err)
74+ }
75+ }
76+ err = d.CreateGlobal(proto.Compositor, 1, func(c server.Client, version, id uint32) {
77+ if _, err := c.CreateResource(proto.Compositor, int32(version), id, compositor); err != nil {
78+ t.Error(err)
79+ }
80+ })
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ ctx, cancel := context.WithCancel(context.Background())
86+ done := make(chan error, 1)
87+ go func() { done <- d.Run(ctx) }()
88+ t.Cleanup(func() {
89+ cancel()
90+ if err := <-done; err != nil {
91+ t.Error(err)
92+ }
93+ })
94+ return socket, d, commits
95+}
96+
97+// Gates 1 and 2: the global exists and wayland-info lists it.
98+func TestWaylandInfoListsCompositor(t *testing.T) {
99+ if _, err := exec.LookPath("wayland-info"); err != nil {
100+ t.Skip("wayland-info not installed")
101+ }
102+ socket, _, _ := startServer(t)
103+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
104+ defer cancel()
105+ cmd := exec.CommandContext(ctx, "wayland-info")
106+ cmd.Env = []string{"XDG_RUNTIME_DIR=" + filepath.Dir(socket), "WAYLAND_DISPLAY=" + filepath.Base(socket)}
107+ out, err := cmd.CombinedOutput()
108+ if err != nil {
109+ t.Fatalf("wayland-info: %v\n%s", err, out)
110+ }
111+ if !strings.Contains(string(out), "wl_compositor") {
112+ t.Fatalf("wl_compositor missing from wayland-info output:\n%s", out)
113+ }
114+}
115+
116+// Gates 3 and 4: requests reach Go handlers, events reach the client, and a
117+// disconnect frees every resource. Run repeatedly and under -race.
118+func TestRequestEventRoundtripAndDisconnect(t *testing.T) {
119+ socket, d, commits := startServer(t)
120+
121+ for round := range 20 {
122+ c, err := wlturbo.Connect(socket)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if err := c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ g, ok := c.Registry().FindGlobal("wl_compositor")
130+ if !ok {
131+ t.Fatal("wl_compositor not advertised")
132+ }
133+ comp, err := c.Registry().BindID(g.Name, g.Interface, 1)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ surf := c.AllocateID()
138+ must(t, c.SendRequest(comp, proto.CompositorCreateSurface, surf))
139+ cb := c.AllocateID()
140+ gotDone := make(chan uint32, 1)
141+ cbProxy := &doneProxy{done: gotDone}
142+ cbProxy.SetID(cb)
143+ c.Context().Register(cbProxy)
144+ must(t, c.SendRequest(surf, proto.SurfaceFrame, cb))
145+ must(t, c.SendRequest(surf, proto.SurfaceCommit))
146+ must(t, c.Roundtrip())
147+
148+ select {
149+ case id := <-commits:
150+ if id != surf {
151+ t.Fatalf("commit on %d, want %d", id, surf)
152+ }
153+ case <-time.After(2 * time.Second):
154+ t.Fatal("commit never reached the Go handler")
155+ }
156+ select {
157+ case serial := <-gotDone:
158+ if serial != uint32(round+1) {
159+ t.Fatalf("callback serial %d, want %d", serial, round+1)
160+ }
161+ default:
162+ t.Fatal("wl_callback.done never reached the client")
163+ }
164+ _ = c.Close()
165+
166+ // libwayland frees the client's resources once it notices the hangup.
167+ deadline := time.Now().Add(2 * time.Second)
168+ for {
169+ var n int
170+ d.Do(func() { n = server.LiveResources() })
171+ if n == 0 {
172+ break
173+ }
174+ if time.Now().After(deadline) {
175+ t.Fatalf("round %d: %d resources still alive after disconnect", round, n)
176+ }
177+ time.Sleep(5 * time.Millisecond)
178+ }
179+ }
180+}
181+
182+// doneProxy records wl_callback.done.
183+type doneProxy struct {
184+ wlturbo.BaseProxy
185+ done chan uint32
186+}
187+
188+func (p *doneProxy) Dispatch(e *wlturbo.Event) {
189+ if e.Opcode == proto.CallbackDone {
190+ p.done <- e.Uint32()
191+ }
192+}
193+
194+func must(t *testing.T, err error) {
195+ t.Helper()
196+ if err != nil {
197+ t.Fatal(err)
198+ }
199+}
@@ -0,0 +1,199 @@
1+package proto_test
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/purego-libwayland/internal/proto"
12+ "github.com/bnema/purego-libwayland/server"
13+ "github.com/bnema/wlturbo"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// startServer runs a display with a wl_compositor global on a private socket.
18+// commits counts wl_surface.commit requests received by Go handlers.
19+func startServer(t *testing.T) (socket string, d *server.Display, commits chan uint32) {
20+ t.Helper()
21+ if err := proto.Init(); err != nil {
22+ t.Fatal(err)
23+ }
24+ d, err := server.NewDisplay()
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ socket = filepath.Join(t.TempDir(), "wayland-test")
29+ // libwayland takes ownership of the listening fd.
30+ fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ if err := unix.Bind(fd, &unix.SockaddrUnix{Name: socket}); err != nil {
35+ t.Fatal(err)
36+ }
37+ if err := unix.Listen(fd, 128); err != nil {
38+ t.Fatal(err)
39+ }
40+ if err := d.AddSocketFD(fd); err != nil {
41+ t.Fatal(err)
42+ }
43+
44+ commits = make(chan uint32, 16)
45+ var serial uint32
46+ surface := func(r *server.Resource, op uint32, args []server.Arg) {
47+ switch op {
48+ case proto.SurfaceDestroy:
49+ r.Destroy()
50+ case proto.SurfaceFrame:
51+ cb, err := r.Client().CreateResource(proto.Callback, 1, args[0].NewID(), nil)
52+ if err != nil {
53+ t.Error(err)
54+ return
55+ }
56+ serial++
57+ cb.PostEvent(proto.CallbackDone, server.Uint(serial))
58+ cb.Destroy()
59+ case proto.SurfaceCommit:
60+ commits <- r.ID()
61+ }
62+ }
63+ compositor := func(r *server.Resource, op uint32, args []server.Arg) {
64+ iface, h := proto.Surface, server.Handler(surface)
65+ if op == proto.CompositorCreateRegion {
66+ iface, h = proto.Region, func(r *server.Resource, op uint32, _ []server.Arg) {
67+ if op == proto.RegionDestroy {
68+ r.Destroy()
69+ }
70+ }
71+ }
72+ if _, err := r.Client().CreateResource(iface, r.Version(), args[0].NewID(), h); err != nil {
73+ t.Error(err)
74+ }
75+ }
76+ err = d.CreateGlobal(proto.Compositor, 1, func(c server.Client, version, id uint32) {
77+ if _, err := c.CreateResource(proto.Compositor, int32(version), id, compositor); err != nil {
78+ t.Error(err)
79+ }
80+ })
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ ctx, cancel := context.WithCancel(context.Background())
86+ done := make(chan error, 1)
87+ go func() { done <- d.Run(ctx) }()
88+ t.Cleanup(func() {
89+ cancel()
90+ if err := <-done; err != nil {
91+ t.Error(err)
92+ }
93+ })
94+ return socket, d, commits
95+}
96+
97+// Gates 1 and 2: the global exists and wayland-info lists it.
98+func TestWaylandInfoListsCompositor(t *testing.T) {
99+ if _, err := exec.LookPath("wayland-info"); err != nil {
100+ t.Skip("wayland-info not installed")
101+ }
102+ socket, _, _ := startServer(t)
103+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
104+ defer cancel()
105+ cmd := exec.CommandContext(ctx, "wayland-info")
106+ cmd.Env = []string{"XDG_RUNTIME_DIR=" + filepath.Dir(socket), "WAYLAND_DISPLAY=" + filepath.Base(socket)}
107+ out, err := cmd.CombinedOutput()
108+ if err != nil {
109+ t.Fatalf("wayland-info: %v\n%s", err, out)
110+ }
111+ if !strings.Contains(string(out), "wl_compositor") {
112+ t.Fatalf("wl_compositor missing from wayland-info output:\n%s", out)
113+ }
114+}
115+
116+// Gates 3 and 4: requests reach Go handlers, events reach the client, and a
117+// disconnect frees every resource. Run repeatedly and under -race.
118+func TestRequestEventRoundtripAndDisconnect(t *testing.T) {
119+ socket, d, commits := startServer(t)
120+
121+ for round := range 20 {
122+ c, err := wlturbo.Connect(socket)
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ if err := c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ g, ok := c.Registry().FindGlobal("wl_compositor")
130+ if !ok {
131+ t.Fatal("wl_compositor not advertised")
132+ }
133+ comp, err := c.Registry().BindID(g.Name, g.Interface, 1)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ surf := c.AllocateID()
138+ must(t, c.SendRequest(comp, proto.CompositorCreateSurface, surf))
139+ cb := c.AllocateID()
140+ gotDone := make(chan uint32, 1)
141+ cbProxy := &doneProxy{done: gotDone}
142+ cbProxy.SetID(cb)
143+ c.Context().Register(cbProxy)
144+ must(t, c.SendRequest(surf, proto.SurfaceFrame, cb))
145+ must(t, c.SendRequest(surf, proto.SurfaceCommit))
146+ must(t, c.Roundtrip())
147+
148+ select {
149+ case id := <-commits:
150+ if id != surf {
151+ t.Fatalf("commit on %d, want %d", id, surf)
152+ }
153+ case <-time.After(2 * time.Second):
154+ t.Fatal("commit never reached the Go handler")
155+ }
156+ select {
157+ case serial := <-gotDone:
158+ if serial != uint32(round+1) {
159+ t.Fatalf("callback serial %d, want %d", serial, round+1)
160+ }
161+ default:
162+ t.Fatal("wl_callback.done never reached the client")
163+ }
164+ _ = c.Close()
165+
166+ // libwayland frees the client's resources once it notices the hangup.
167+ deadline := time.Now().Add(2 * time.Second)
168+ for {
169+ var n int
170+ d.Do(func() { n = server.LiveResources() })
171+ if n == 0 {
172+ break
173+ }
174+ if time.Now().After(deadline) {
175+ t.Fatalf("round %d: %d resources still alive after disconnect", round, n)
176+ }
177+ time.Sleep(5 * time.Millisecond)
178+ }
179+ }
180+}
181+
182+// doneProxy records wl_callback.done.
183+type doneProxy struct {
184+ wlturbo.BaseProxy
185+ done chan uint32
186+}
187+
188+func (p *doneProxy) Dispatch(e *wlturbo.Event) {
189+ if e.Opcode == proto.CallbackDone {
190+ p.done <- e.Uint32()
191+ }
192+}
193+
194+func must(t *testing.T, err error) {
195+ t.Helper()
196+ if err != nil {
197+ t.Fatal(err)
198+ }
199+}
A server/arena.go +46 -0
@@ -0,0 +1,46 @@
1+package server
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// arena is C-visible memory the Go GC never moves or frees. It holds
12+// wl_interface and wl_message tables and their strings for the whole
13+// process lifetime.
14+type arena struct {
15+ mem []byte
16+ base uintptr
17+ off int
18+}
19+
20+func newArena(size int) (*arena, error) {
21+ mem, err := unix.Mmap(-1, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS)
22+ if err != nil {
23+ return nil, fmt.Errorf("arena: mmap: %w", err)
24+ }
25+ return &arena{mem: mem, base: uintptr(unsafe.Pointer(&mem[0]))}, nil
26+}
27+
28+// alloc returns the offset and C address of n zeroed, 8-byte aligned bytes.
29+func (a *arena) alloc(n int) (int, uintptr) {
30+ a.off = (a.off + 7) &^ 7
31+ if a.off+n > len(a.mem) {
32+ panic("purego-libwayland: arena exhausted")
33+ }
34+ off := a.off
35+ a.off += n
36+ return off, a.base + uintptr(off)
37+}
38+
39+func (a *arena) cstring(s string) uintptr {
40+ off, addr := a.alloc(len(s) + 1)
41+ copy(a.mem[off:], s)
42+ return addr
43+}
44+
45+func (a *arena) putPtr(off int, v uintptr) { binary.LittleEndian.PutUint64(a.mem[off:], uint64(v)) }
46+func (a *arena) putI32(off int, v int32) { binary.LittleEndian.PutUint32(a.mem[off:], uint32(v)) }
@@ -0,0 +1,46 @@
1+package server
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "unsafe"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+// arena is C-visible memory the Go GC never moves or frees. It holds
12+// wl_interface and wl_message tables and their strings for the whole
13+// process lifetime.
14+type arena struct {
15+ mem []byte
16+ base uintptr
17+ off int
18+}
19+
20+func newArena(size int) (*arena, error) {
21+ mem, err := unix.Mmap(-1, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANONYMOUS)
22+ if err != nil {
23+ return nil, fmt.Errorf("arena: mmap: %w", err)
24+ }
25+ return &arena{mem: mem, base: uintptr(unsafe.Pointer(&mem[0]))}, nil
26+}
27+
28+// alloc returns the offset and C address of n zeroed, 8-byte aligned bytes.
29+func (a *arena) alloc(n int) (int, uintptr) {
30+ a.off = (a.off + 7) &^ 7
31+ if a.off+n > len(a.mem) {
32+ panic("purego-libwayland: arena exhausted")
33+ }
34+ off := a.off
35+ a.off += n
36+ return off, a.base + uintptr(off)
37+}
38+
39+func (a *arena) cstring(s string) uintptr {
40+ off, addr := a.alloc(len(s) + 1)
41+ copy(a.mem[off:], s)
42+ return addr
43+}
44+
45+func (a *arena) putPtr(off int, v uintptr) { binary.LittleEndian.PutUint64(a.mem[off:], uint64(v)) }
46+func (a *arena) putI32(off int, v int32) { binary.LittleEndian.PutUint32(a.mem[off:], uint32(v)) }
A server/display.go +204 -0
@@ -0,0 +1,204 @@
1+package server
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "runtime"
8+ "unsafe"
9+)
10+
11+// Arg is one raw union wl_argument value (int, uint, fixed, object pointer,
12+// new_id, or fd).
13+type Arg uint64
14+
15+func Uint(v uint32) Arg { return Arg(v) }
16+func Int(v int32) Arg { return Arg(uint32(v)) }
17+func Object(r *Resource) Arg { return Arg(r.c) }
18+func (a Arg) Uint() uint32 { return uint32(a) }
19+func (a Arg) Int() int32 { return int32(uint32(a)) }
20+func (a Arg) NewID() uint32 { return uint32(a) }
21+func (a Arg) Resource() *Resource { return live.resources[uintptr(a)] }
22+
23+// Handler receives every request sent to a resource.
24+type Handler func(r *Resource, opcode uint32, args []Arg)
25+
26+// BindFunc runs when a client binds a global.
27+type BindFunc func(c Client, version, id uint32)
28+
29+// Client is a connected wl_client.
30+type Client struct{ c uintptr }
31+
32+// Resource is a server-side wl_resource.
33+type Resource struct {
34+ c uintptr
35+ iface *Interface
36+ handler Handler
37+ OnDestroy func()
38+}
39+
40+// live holds Go state reachable from C callbacks. It is only touched on the
41+// display goroutine, so it needs no lock.
42+var live = struct {
43+ resources map[uintptr]*Resource
44+ binds map[uintptr]BindFunc
45+ nextBind uintptr
46+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}}
47+
48+// Display owns a wl_display. All methods except Do must run on the goroutine
49+// that calls Run, or before Run starts.
50+type Display struct {
51+ c uintptr
52+ loop uintptr
53+ calls chan func()
54+}
55+
56+func NewDisplay() (*Display, error) {
57+ if err := load(); err != nil {
58+ return nil, err
59+ }
60+ c := wlDisplayCreate()
61+ if c == 0 {
62+ return nil, errors.New("purego-libwayland: wl_display_create failed")
63+ }
64+ return &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func())}, nil
65+}
66+
67+// AddSocketFD serves clients on an already bound, listening unix socket.
68+func (d *Display) AddSocketFD(fd int) error {
69+ if wlDisplayAddSocketFD(d.c, int32(fd)) != 0 {
70+ return fmt.Errorf("purego-libwayland: wl_display_add_socket_fd(%d) failed", fd)
71+ }
72+ return nil
73+}
74+
75+func (d *Display) CreateGlobal(iface *Interface, version int32, bindFn BindFunc) error {
76+ live.nextBind++
77+ token := live.nextBind
78+ live.binds[token] = bindFn
79+ if wlGlobalCreate(d.c, iface.c, version, token, cbBind) == 0 {
80+ delete(live.binds, token)
81+ return fmt.Errorf("purego-libwayland: wl_global_create(%s) failed", iface.Name)
82+ }
83+ return nil
84+}
85+
86+// Run dispatches libwayland on one OS-locked goroutine until ctx is done.
87+func (d *Display) Run(ctx context.Context) error {
88+ runtime.LockOSThread()
89+ defer runtime.UnlockOSThread()
90+ for {
91+ select {
92+ case <-ctx.Done():
93+ wlDisplayDestroyClients(d.c)
94+ wlDisplayDestroy(d.c)
95+ return nil
96+ case fn := <-d.calls:
97+ fn()
98+ default:
99+ }
100+ if wlEventLoopDispatch(d.loop, 5) < 0 {
101+ return errors.New("purego-libwayland: wl_event_loop_dispatch failed")
102+ }
103+ wlDisplayFlushClients(d.c)
104+ }
105+}
106+
107+// Do runs fn on the display goroutine and waits for it.
108+func (d *Display) Do(fn func()) {
109+ done := make(chan struct{})
110+ d.calls <- func() { fn(); close(done) }
111+ <-done
112+}
113+
114+// LiveResources reports resources not yet destroyed. Display goroutine only.
115+func LiveResources() int { return len(live.resources) }
116+
117+func (c Client) CreateResource(iface *Interface, version int32, id uint32, h Handler) (*Resource, error) {
118+ rc := wlResourceCreate(c.c, iface.c, version, id)
119+ if rc == 0 {
120+ return nil, fmt.Errorf("purego-libwayland: wl_resource_create(%s) failed", iface.Name)
121+ }
122+ r := &Resource{c: rc, iface: iface, handler: h}
123+ live.resources[rc] = r
124+ wlResourceSetDispatcher(rc, cbDispatcher, implMarker(), 0, cbDestroy)
125+ return r, nil
126+}
127+
128+func (r *Resource) ID() uint32 { return wlResourceGetID(r.c) }
129+func (r *Resource) Version() int32 { return wlResourceGetVersion(r.c) }
130+func (r *Resource) Client() Client { return Client{wlResourceGetClient(r.c)} }
131+func (r *Resource) Destroy() { wlResourceDestroy(r.c) }
132+func (r *Resource) Iface() *Interface { return r.iface }
133+
134+// PostEvent sends an event. String and array arguments are not supported yet.
135+func (r *Resource) PostEvent(opcode uint32, args ...Arg) {
136+ var p unsafe.Pointer
137+ if len(args) > 0 {
138+ p = unsafe.Pointer(&args[0])
139+ }
140+ wlResourcePostEventArr(r.c, opcode, p)
141+ runtime.KeepAlive(args)
142+}
143+
144+// implMarker is a non-nil implementation pointer. libwayland only passes it
145+// back to our dispatcher.
146+func implMarker() uintptr {
147+ if marker == 0 {
148+ _, marker = tables.alloc(8)
149+ }
150+ return marker
151+}
152+
153+var marker uintptr
154+
155+// --- C callbacks (display goroutine) ---
156+
157+func dispatch(_ uintptr, target uintptr, opcode uint32, msg unsafe.Pointer, args unsafe.Pointer) int32 {
158+ r := live.resources[target]
159+ if r == nil || r.handler == nil {
160+ return 0
161+ }
162+ n := argCount(msgSignature(msg))
163+ var a []Arg
164+ if n > 0 {
165+ a = append([]Arg(nil), unsafe.Slice((*Arg)(args), n)...)
166+ }
167+ r.handler(r, opcode, a)
168+ return 0
169+}
170+
171+func destroyed(res uintptr) {
172+ r := live.resources[res]
173+ delete(live.resources, res)
174+ if r != nil && r.OnDestroy != nil {
175+ r.OnDestroy()
176+ }
177+}
178+
179+func bind(client uintptr, data uintptr, version uint32, id uint32) {
180+ if fn := live.binds[data]; fn != nil {
181+ fn(Client{client}, version, id)
182+ }
183+}
184+
185+// msgSignature reads wl_message.signature.
186+func msgSignature(msg unsafe.Pointer) string {
187+ p := *(*unsafe.Pointer)(unsafe.Add(msg, 8))
188+ n := 0
189+ for *(*byte)(unsafe.Add(p, n)) != 0 {
190+ n++
191+ }
192+ return string(unsafe.Slice((*byte)(p), n))
193+}
194+
195+// argCount counts arguments in a libwayland signature ("?oii", "2n", ...).
196+func argCount(sig string) int {
197+ n := 0
198+ for _, ch := range sig {
199+ if ch != '?' && (ch < '0' || ch > '9') {
200+ n++
201+ }
202+ }
203+ return n
204+}
@@ -0,0 +1,204 @@
1+package server
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "runtime"
8+ "unsafe"
9+)
10+
11+// Arg is one raw union wl_argument value (int, uint, fixed, object pointer,
12+// new_id, or fd).
13+type Arg uint64
14+
15+func Uint(v uint32) Arg { return Arg(v) }
16+func Int(v int32) Arg { return Arg(uint32(v)) }
17+func Object(r *Resource) Arg { return Arg(r.c) }
18+func (a Arg) Uint() uint32 { return uint32(a) }
19+func (a Arg) Int() int32 { return int32(uint32(a)) }
20+func (a Arg) NewID() uint32 { return uint32(a) }
21+func (a Arg) Resource() *Resource { return live.resources[uintptr(a)] }
22+
23+// Handler receives every request sent to a resource.
24+type Handler func(r *Resource, opcode uint32, args []Arg)
25+
26+// BindFunc runs when a client binds a global.
27+type BindFunc func(c Client, version, id uint32)
28+
29+// Client is a connected wl_client.
30+type Client struct{ c uintptr }
31+
32+// Resource is a server-side wl_resource.
33+type Resource struct {
34+ c uintptr
35+ iface *Interface
36+ handler Handler
37+ OnDestroy func()
38+}
39+
40+// live holds Go state reachable from C callbacks. It is only touched on the
41+// display goroutine, so it needs no lock.
42+var live = struct {
43+ resources map[uintptr]*Resource
44+ binds map[uintptr]BindFunc
45+ nextBind uintptr
46+}{resources: map[uintptr]*Resource{}, binds: map[uintptr]BindFunc{}}
47+
48+// Display owns a wl_display. All methods except Do must run on the goroutine
49+// that calls Run, or before Run starts.
50+type Display struct {
51+ c uintptr
52+ loop uintptr
53+ calls chan func()
54+}
55+
56+func NewDisplay() (*Display, error) {
57+ if err := load(); err != nil {
58+ return nil, err
59+ }
60+ c := wlDisplayCreate()
61+ if c == 0 {
62+ return nil, errors.New("purego-libwayland: wl_display_create failed")
63+ }
64+ return &Display{c: c, loop: wlDisplayGetEventLoop(c), calls: make(chan func())}, nil
65+}
66+
67+// AddSocketFD serves clients on an already bound, listening unix socket.
68+func (d *Display) AddSocketFD(fd int) error {
69+ if wlDisplayAddSocketFD(d.c, int32(fd)) != 0 {
70+ return fmt.Errorf("purego-libwayland: wl_display_add_socket_fd(%d) failed", fd)
71+ }
72+ return nil
73+}
74+
75+func (d *Display) CreateGlobal(iface *Interface, version int32, bindFn BindFunc) error {
76+ live.nextBind++
77+ token := live.nextBind
78+ live.binds[token] = bindFn
79+ if wlGlobalCreate(d.c, iface.c, version, token, cbBind) == 0 {
80+ delete(live.binds, token)
81+ return fmt.Errorf("purego-libwayland: wl_global_create(%s) failed", iface.Name)
82+ }
83+ return nil
84+}
85+
86+// Run dispatches libwayland on one OS-locked goroutine until ctx is done.
87+func (d *Display) Run(ctx context.Context) error {
88+ runtime.LockOSThread()
89+ defer runtime.UnlockOSThread()
90+ for {
91+ select {
92+ case <-ctx.Done():
93+ wlDisplayDestroyClients(d.c)
94+ wlDisplayDestroy(d.c)
95+ return nil
96+ case fn := <-d.calls:
97+ fn()
98+ default:
99+ }
100+ if wlEventLoopDispatch(d.loop, 5) < 0 {
101+ return errors.New("purego-libwayland: wl_event_loop_dispatch failed")
102+ }
103+ wlDisplayFlushClients(d.c)
104+ }
105+}
106+
107+// Do runs fn on the display goroutine and waits for it.
108+func (d *Display) Do(fn func()) {
109+ done := make(chan struct{})
110+ d.calls <- func() { fn(); close(done) }
111+ <-done
112+}
113+
114+// LiveResources reports resources not yet destroyed. Display goroutine only.
115+func LiveResources() int { return len(live.resources) }
116+
117+func (c Client) CreateResource(iface *Interface, version int32, id uint32, h Handler) (*Resource, error) {
118+ rc := wlResourceCreate(c.c, iface.c, version, id)
119+ if rc == 0 {
120+ return nil, fmt.Errorf("purego-libwayland: wl_resource_create(%s) failed", iface.Name)
121+ }
122+ r := &Resource{c: rc, iface: iface, handler: h}
123+ live.resources[rc] = r
124+ wlResourceSetDispatcher(rc, cbDispatcher, implMarker(), 0, cbDestroy)
125+ return r, nil
126+}
127+
128+func (r *Resource) ID() uint32 { return wlResourceGetID(r.c) }
129+func (r *Resource) Version() int32 { return wlResourceGetVersion(r.c) }
130+func (r *Resource) Client() Client { return Client{wlResourceGetClient(r.c)} }
131+func (r *Resource) Destroy() { wlResourceDestroy(r.c) }
132+func (r *Resource) Iface() *Interface { return r.iface }
133+
134+// PostEvent sends an event. String and array arguments are not supported yet.
135+func (r *Resource) PostEvent(opcode uint32, args ...Arg) {
136+ var p unsafe.Pointer
137+ if len(args) > 0 {
138+ p = unsafe.Pointer(&args[0])
139+ }
140+ wlResourcePostEventArr(r.c, opcode, p)
141+ runtime.KeepAlive(args)
142+}
143+
144+// implMarker is a non-nil implementation pointer. libwayland only passes it
145+// back to our dispatcher.
146+func implMarker() uintptr {
147+ if marker == 0 {
148+ _, marker = tables.alloc(8)
149+ }
150+ return marker
151+}
152+
153+var marker uintptr
154+
155+// --- C callbacks (display goroutine) ---
156+
157+func dispatch(_ uintptr, target uintptr, opcode uint32, msg unsafe.Pointer, args unsafe.Pointer) int32 {
158+ r := live.resources[target]
159+ if r == nil || r.handler == nil {
160+ return 0
161+ }
162+ n := argCount(msgSignature(msg))
163+ var a []Arg
164+ if n > 0 {
165+ a = append([]Arg(nil), unsafe.Slice((*Arg)(args), n)...)
166+ }
167+ r.handler(r, opcode, a)
168+ return 0
169+}
170+
171+func destroyed(res uintptr) {
172+ r := live.resources[res]
173+ delete(live.resources, res)
174+ if r != nil && r.OnDestroy != nil {
175+ r.OnDestroy()
176+ }
177+}
178+
179+func bind(client uintptr, data uintptr, version uint32, id uint32) {
180+ if fn := live.binds[data]; fn != nil {
181+ fn(Client{client}, version, id)
182+ }
183+}
184+
185+// msgSignature reads wl_message.signature.
186+func msgSignature(msg unsafe.Pointer) string {
187+ p := *(*unsafe.Pointer)(unsafe.Add(msg, 8))
188+ n := 0
189+ for *(*byte)(unsafe.Add(p, n)) != 0 {
190+ n++
191+ }
192+ return string(unsafe.Slice((*byte)(p), n))
193+}
194+
195+// argCount counts arguments in a libwayland signature ("?oii", "2n", ...).
196+func argCount(sig string) int {
197+ n := 0
198+ for _, ch := range sig {
199+ if ch != '?' && (ch < '0' || ch > '9') {
200+ n++
201+ }
202+ }
203+ return n
204+}
A server/interface.go +92 -0
@@ -0,0 +1,92 @@
1+package server
2+
3+// Message describes one request or event. Signature uses the libwayland
4+// format ("n", "?oii", ...). Types holds one entry per argument; entries are
5+// non-nil only for typed new_id and object arguments.
6+type Message struct {
7+ Name string
8+ Signature string
9+ Types []*Interface
10+}
11+
12+// Interface describes a protocol interface. Build it once with
13+// NewInterfaces; its C table lives for the process lifetime.
14+type Interface struct {
15+ Name string
16+ Version int32
17+ Requests []Message
18+ Events []Message
19+
20+ c uintptr // struct wl_interface*
21+}
22+
23+// C layout (LP64):
24+//
25+// struct wl_message { const char *name; const char *signature; const struct wl_interface **types; } // 24 bytes
26+// struct wl_interface { const char *name; int version; int method_count; const struct wl_message *methods;
27+// int event_count; const struct wl_message *events; } // 40 bytes
28+const (
29+ sizeMessage = 24
30+ sizeInterface = 40
31+)
32+
33+var tables *arena
34+
35+// NewInterfaces writes C tables for a set of interfaces that may reference
36+// each other. It must be called before any interface is used.
37+func NewInterfaces(ifaces ...*Interface) error {
38+ if err := load(); err != nil {
39+ return err
40+ }
41+ if tables == nil {
42+ a, err := newArena(1 << 20)
43+ if err != nil {
44+ return err
45+ }
46+ tables = a
47+ }
48+ a := tables
49+ // Pass 1: reserve every wl_interface so cross references resolve.
50+ offs := make([]int, len(ifaces))
51+ for i, it := range ifaces {
52+ offs[i], it.c = a.alloc(sizeInterface)
53+ }
54+ // Pass 2: fill messages and headers.
55+ for i, it := range ifaces {
56+ o := offs[i]
57+ a.putPtr(o, a.cstring(it.Name))
58+ a.putI32(o+8, it.Version)
59+ a.putI32(o+12, int32(len(it.Requests)))
60+ a.putPtr(o+16, writeMessages(a, it.Requests))
61+ a.putI32(o+24, int32(len(it.Events)))
62+ a.putPtr(o+32, writeMessages(a, it.Events))
63+ }
64+ return nil
65+}
66+
67+func writeMessages(a *arena, msgs []Message) uintptr {
68+ if len(msgs) == 0 {
69+ return 0
70+ }
71+ off, addr := a.alloc(sizeMessage * len(msgs))
72+ for i, m := range msgs {
73+ var types uintptr
74+ if len(m.Types) > 0 {
75+ toff, taddr := a.alloc(8 * len(m.Types))
76+ for j, t := range m.Types {
77+ if t != nil {
78+ if t.c == 0 {
79+ panic("purego-libwayland: interface " + t.Name + " referenced before NewInterfaces")
80+ }
81+ a.putPtr(toff+8*j, t.c)
82+ }
83+ }
84+ types = taddr
85+ }
86+ mo := off + i*sizeMessage
87+ a.putPtr(mo, a.cstring(m.Name))
88+ a.putPtr(mo+8, a.cstring(m.Signature))
89+ a.putPtr(mo+16, types)
90+ }
91+ return addr
92+}
@@ -0,0 +1,92 @@
1+package server
2+
3+// Message describes one request or event. Signature uses the libwayland
4+// format ("n", "?oii", ...). Types holds one entry per argument; entries are
5+// non-nil only for typed new_id and object arguments.
6+type Message struct {
7+ Name string
8+ Signature string
9+ Types []*Interface
10+}
11+
12+// Interface describes a protocol interface. Build it once with
13+// NewInterfaces; its C table lives for the process lifetime.
14+type Interface struct {
15+ Name string
16+ Version int32
17+ Requests []Message
18+ Events []Message
19+
20+ c uintptr // struct wl_interface*
21+}
22+
23+// C layout (LP64):
24+//
25+// struct wl_message { const char *name; const char *signature; const struct wl_interface **types; } // 24 bytes
26+// struct wl_interface { const char *name; int version; int method_count; const struct wl_message *methods;
27+// int event_count; const struct wl_message *events; } // 40 bytes
28+const (
29+ sizeMessage = 24
30+ sizeInterface = 40
31+)
32+
33+var tables *arena
34+
35+// NewInterfaces writes C tables for a set of interfaces that may reference
36+// each other. It must be called before any interface is used.
37+func NewInterfaces(ifaces ...*Interface) error {
38+ if err := load(); err != nil {
39+ return err
40+ }
41+ if tables == nil {
42+ a, err := newArena(1 << 20)
43+ if err != nil {
44+ return err
45+ }
46+ tables = a
47+ }
48+ a := tables
49+ // Pass 1: reserve every wl_interface so cross references resolve.
50+ offs := make([]int, len(ifaces))
51+ for i, it := range ifaces {
52+ offs[i], it.c = a.alloc(sizeInterface)
53+ }
54+ // Pass 2: fill messages and headers.
55+ for i, it := range ifaces {
56+ o := offs[i]
57+ a.putPtr(o, a.cstring(it.Name))
58+ a.putI32(o+8, it.Version)
59+ a.putI32(o+12, int32(len(it.Requests)))
60+ a.putPtr(o+16, writeMessages(a, it.Requests))
61+ a.putI32(o+24, int32(len(it.Events)))
62+ a.putPtr(o+32, writeMessages(a, it.Events))
63+ }
64+ return nil
65+}
66+
67+func writeMessages(a *arena, msgs []Message) uintptr {
68+ if len(msgs) == 0 {
69+ return 0
70+ }
71+ off, addr := a.alloc(sizeMessage * len(msgs))
72+ for i, m := range msgs {
73+ var types uintptr
74+ if len(m.Types) > 0 {
75+ toff, taddr := a.alloc(8 * len(m.Types))
76+ for j, t := range m.Types {
77+ if t != nil {
78+ if t.c == 0 {
79+ panic("purego-libwayland: interface " + t.Name + " referenced before NewInterfaces")
80+ }
81+ a.putPtr(toff+8*j, t.c)
82+ }
83+ }
84+ types = taddr
85+ }
86+ mo := off + i*sizeMessage
87+ a.putPtr(mo, a.cstring(m.Name))
88+ a.putPtr(mo+8, a.cstring(m.Signature))
89+ a.putPtr(mo+16, types)
90+ }
91+ return addr
92+}
A server/lib.go +79 -0
@@ -0,0 +1,79 @@
1+package server
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "unsafe"
7+
8+ "github.com/bnema/purego"
9+)
10+
11+// libwayland-server entry points. Only non-varargs functions are bound.
12+var (
13+ wlDisplayCreate func() uintptr
14+ wlDisplayDestroy func(display uintptr)
15+ wlDisplayDestroyClients func(display uintptr)
16+ wlDisplayAddSocketFD func(display uintptr, fd int32) int32
17+ wlDisplayGetEventLoop func(display uintptr) uintptr
18+ wlDisplayFlushClients func(display uintptr)
19+ wlEventLoopDispatch func(loop uintptr, timeout int32) int32
20+ wlEventLoopGetFD func(loop uintptr) int32
21+ wlGlobalCreate func(display, iface uintptr, version int32, data uintptr, bind uintptr) uintptr
22+ wlResourceCreate func(client, iface uintptr, version int32, id uint32) uintptr
23+ wlResourceSetDispatcher func(resource, dispatcher, impl, data, destroy uintptr)
24+ wlResourcePostEventArr func(resource uintptr, opcode uint32, args unsafe.Pointer)
25+ wlResourcePostError func(resource uintptr, code uint32, msg uintptr)
26+ wlResourceDestroy func(resource uintptr)
27+ wlResourceGetID func(resource uintptr) uint32
28+ wlResourceGetClient func(resource uintptr) uintptr
29+ wlResourceGetVersion func(resource uintptr) int32
30+)
31+
32+var (
33+ loadOnce sync.Once
34+ loadErr error
35+
36+ // Shared C callbacks. purego callbacks are never freed, so there is one
37+ // of each for the whole process.
38+ cbDispatcher uintptr
39+ cbDestroy uintptr
40+ cbBind uintptr
41+)
42+
43+func load() error {
44+ loadOnce.Do(func() {
45+ lib, err := purego.Dlopen("libwayland-server.so.0", purego.RTLD_NOW|purego.RTLD_GLOBAL)
46+ if err != nil {
47+ loadErr = fmt.Errorf("purego-libwayland: open libwayland-server.so.0: %w", err)
48+ return
49+ }
50+ defer func() {
51+ if r := recover(); r != nil {
52+ loadErr = fmt.Errorf("purego-libwayland: %v", r)
53+ }
54+ }()
55+ reg := func(fptr any, name string) { purego.RegisterLibFunc(fptr, lib, name) }
56+ reg(&wlDisplayCreate, "wl_display_create")
57+ reg(&wlDisplayDestroy, "wl_display_destroy")
58+ reg(&wlDisplayDestroyClients, "wl_display_destroy_clients")
59+ reg(&wlDisplayAddSocketFD, "wl_display_add_socket_fd")
60+ reg(&wlDisplayGetEventLoop, "wl_display_get_event_loop")
61+ reg(&wlDisplayFlushClients, "wl_display_flush_clients")
62+ reg(&wlEventLoopDispatch, "wl_event_loop_dispatch")
63+ reg(&wlEventLoopGetFD, "wl_event_loop_get_fd")
64+ reg(&wlGlobalCreate, "wl_global_create")
65+ reg(&wlResourceCreate, "wl_resource_create")
66+ reg(&wlResourceSetDispatcher, "wl_resource_set_dispatcher")
67+ reg(&wlResourcePostEventArr, "wl_resource_post_event_array")
68+ reg(&wlResourcePostError, "wl_resource_post_error")
69+ reg(&wlResourceDestroy, "wl_resource_destroy")
70+ reg(&wlResourceGetID, "wl_resource_get_id")
71+ reg(&wlResourceGetClient, "wl_resource_get_client")
72+ reg(&wlResourceGetVersion, "wl_resource_get_version")
73+
74+ cbDispatcher = purego.NewCallback(dispatch)
75+ cbDestroy = purego.NewCallback(destroyed)
76+ cbBind = purego.NewCallback(bind)
77+ })
78+ return loadErr
79+}
@@ -0,0 +1,79 @@
1+package server
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "unsafe"
7+
8+ "github.com/bnema/purego"
9+)
10+
11+// libwayland-server entry points. Only non-varargs functions are bound.
12+var (
13+ wlDisplayCreate func() uintptr
14+ wlDisplayDestroy func(display uintptr)
15+ wlDisplayDestroyClients func(display uintptr)
16+ wlDisplayAddSocketFD func(display uintptr, fd int32) int32
17+ wlDisplayGetEventLoop func(display uintptr) uintptr
18+ wlDisplayFlushClients func(display uintptr)
19+ wlEventLoopDispatch func(loop uintptr, timeout int32) int32
20+ wlEventLoopGetFD func(loop uintptr) int32
21+ wlGlobalCreate func(display, iface uintptr, version int32, data uintptr, bind uintptr) uintptr
22+ wlResourceCreate func(client, iface uintptr, version int32, id uint32) uintptr
23+ wlResourceSetDispatcher func(resource, dispatcher, impl, data, destroy uintptr)
24+ wlResourcePostEventArr func(resource uintptr, opcode uint32, args unsafe.Pointer)
25+ wlResourcePostError func(resource uintptr, code uint32, msg uintptr)
26+ wlResourceDestroy func(resource uintptr)
27+ wlResourceGetID func(resource uintptr) uint32
28+ wlResourceGetClient func(resource uintptr) uintptr
29+ wlResourceGetVersion func(resource uintptr) int32
30+)
31+
32+var (
33+ loadOnce sync.Once
34+ loadErr error
35+
36+ // Shared C callbacks. purego callbacks are never freed, so there is one
37+ // of each for the whole process.
38+ cbDispatcher uintptr
39+ cbDestroy uintptr
40+ cbBind uintptr
41+)
42+
43+func load() error {
44+ loadOnce.Do(func() {
45+ lib, err := purego.Dlopen("libwayland-server.so.0", purego.RTLD_NOW|purego.RTLD_GLOBAL)
46+ if err != nil {
47+ loadErr = fmt.Errorf("purego-libwayland: open libwayland-server.so.0: %w", err)
48+ return
49+ }
50+ defer func() {
51+ if r := recover(); r != nil {
52+ loadErr = fmt.Errorf("purego-libwayland: %v", r)
53+ }
54+ }()
55+ reg := func(fptr any, name string) { purego.RegisterLibFunc(fptr, lib, name) }
56+ reg(&wlDisplayCreate, "wl_display_create")
57+ reg(&wlDisplayDestroy, "wl_display_destroy")
58+ reg(&wlDisplayDestroyClients, "wl_display_destroy_clients")
59+ reg(&wlDisplayAddSocketFD, "wl_display_add_socket_fd")
60+ reg(&wlDisplayGetEventLoop, "wl_display_get_event_loop")
61+ reg(&wlDisplayFlushClients, "wl_display_flush_clients")
62+ reg(&wlEventLoopDispatch, "wl_event_loop_dispatch")
63+ reg(&wlEventLoopGetFD, "wl_event_loop_get_fd")
64+ reg(&wlGlobalCreate, "wl_global_create")
65+ reg(&wlResourceCreate, "wl_resource_create")
66+ reg(&wlResourceSetDispatcher, "wl_resource_set_dispatcher")
67+ reg(&wlResourcePostEventArr, "wl_resource_post_event_array")
68+ reg(&wlResourcePostError, "wl_resource_post_error")
69+ reg(&wlResourceDestroy, "wl_resource_destroy")
70+ reg(&wlResourceGetID, "wl_resource_get_id")
71+ reg(&wlResourceGetClient, "wl_resource_get_client")
72+ reg(&wlResourceGetVersion, "wl_resource_get_version")
73+
74+ cbDispatcher = purego.NewCallback(dispatch)
75+ cbDestroy = purego.NewCallback(destroyed)
76+ cbBind = purego.NewCallback(bind)
77+ })
78+ return loadErr
79+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 14

↑↓ moveenter openesc close