1package wayland
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "regexp"
8 "slices"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/go-wayland-bindings/server/wayland"
14 "github.com/bnema/neferwl/internal/adapters/logging"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/wlturbo"
17 "golang.org/x/sys/unix"
18)
19
20func keymapText(t *testing.T) string {
21 t.Helper()
22 data, err := os.ReadFile("testdata/us.xkb")
23 if err != nil {
24 t.Fatal(err)
25 }
26 return string(data)
27}
28
29// keyboardServer runs a server; setup, if any, runs before Run starts.
30func keyboardServer(t *testing.T, setup ...func(*Server)) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
31 t.Helper()
32 dir := t.TempDir()
33 events := make(chan ports.ClientEvent, 16)
34 commands := make(chan ports.ClientCommand, 32)
35 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Keymap: keymapText(t), RepeatRate: 25, RepeatDelay: 600}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
36 if err != nil {
37 t.Fatal(err)
38 }
39 for _, f := range setup {
40 f(s)
41 }
42 ctx, cancel := context.WithCancel(context.Background())
43 done := make(chan error, 1)
44 go func() { done <- s.Run(ctx) }()
45 t.Cleanup(func() {
46 cancel()
47 select {
48 case err := <-done:
49 if err != nil {
50 t.Errorf("Run: %v", err)
51 }
52 case <-time.After(2 * time.Second):
53 t.Error("Run hung")
54 }
55 })
56 return s, events, commands, dir
57}
58
59func TestKeyboardInfo(t *testing.T) {
60 tool, err := exec.LookPath("wayland-info")
61 if err != nil {
62 t.Skip("wayland-info not installed")
63 }
64 s, _, _, dir := keyboardServer(t)
65 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
66 defer cancel()
67 cmd := exec.CommandContext(ctx, tool)
68 cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
69 out, err := cmd.CombinedOutput()
70 if err != nil {
71 t.Fatalf("wayland-info: %v: %s", err, out)
72 }
73 for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {
74 if !strings.Contains(string(out), want) {
75 t.Errorf("missing %q: %s", want, out)
76 }
77 }
78}
79
80type keymapProxy struct {
81 wlturbo.BaseProxy
82 result chan error
83}
84
85func (p *keymapProxy) Dispatch(e *wlturbo.Event) {
86 if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
87 return
88 }
89 format := e.Uint32()
90 fd := int(e.Fd())
91 size := e.Uint32()
92 if fd <= 0 || size == 0 || format != uint32(wayland.KeyboardKeymapFormatXkbV1) {
93 p.result <- os.ErrInvalid
94 return
95 }
96 defer unix.Close(fd)
97 data := make([]byte, size)
98 _, err := unix.Pread(fd, data, 0)
99 if err == nil && !strings.HasPrefix(string(data), "xkb_keymap") {
100 err = os.ErrInvalid
101 }
102 p.result <- err
103}
104func TestKeyboardKeymapProtocol(t *testing.T) {
105 s, _, _, dir := keyboardServer(t)
106 c := protocolClient(t, s, dir)
107 seat := bindProtocol(t, c, "wl_seat")
108 registerProtocol(t, c, seat)
109 id := c.AllocateID()
110 proxy := &keymapProxy{result: make(chan error, 1)}
111 proxy.SetID(id)
112 registerWireProxy(c, proxy)
113 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
114 if err := c.Roundtrip(); err != nil {
115 t.Fatal(err)
116 }
117 select {
118 case err := <-proxy.result:
119 if err != nil {
120 t.Fatal(err)
121 }
122 default:
123 t.Fatal("missing keymap event")
124 }
125}
126
127// events records keyboard event opcodes; a keymap event also checks its content.
128type eventsProxy struct {
129 wlturbo.BaseProxy
130 opcodes []uint16
131 keymaps []string
132 locked []uint32 // locked modifiers of each modifiers event
133 depress []uint32 // depressed modifiers of each modifiers event
134}
135
136func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
137 p.opcodes = append(p.opcodes, e.Opcode)
138 if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
139 e.Uint32() // serial
140 p.depress = append(p.depress, e.Uint32())
141 e.Uint32() // latched
142 p.locked = append(p.locked, e.Uint32())
143 return
144 }
145 if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
146 return
147 }
148 e.Uint32()
149 fd := int(e.Fd())
150 size := e.Uint32()
151 defer unix.Close(fd)
152 data := make([]byte, size)
153 if _, err := unix.Pread(fd, data, 0); err == nil {
154 p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
155 }
156}
157
158func TestSetKeymap(t *testing.T) {
159 s, _, commands, dir := keyboardServer(t)
160 c := protocolClient(t, s, dir)
161 // Version 4 so the keyboard receives repeat_info.
162 g, ok := c.Registry().FindGlobal("wl_seat")
163 if !ok {
164 t.Fatal("missing wl_seat")
165 }
166 seat, err := bindWireID(c, g.Name, g.Interface, 4)
167 if err != nil {
168 t.Fatal(err)
169 }
170 registerProtocol(t, c, seat)
171 id := c.AllocateID()
172 proxy := &eventsProxy{}
173 proxy.SetID(id)
174 registerWireProxy(c, proxy)
175 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
176 if err := c.Roundtrip(); err != nil {
177 t.Fatal(err)
178 }
179 proxy.opcodes, proxy.keymaps = nil, nil
180 sync := func() {
181 t.Helper()
182 // Commands apply on the display goroutine; wait until it has run them.
183 deadline := time.Now().Add(time.Second)
184 for len(commands) > 0 && time.Now().Before(deadline) {
185 time.Sleep(5 * time.Millisecond)
186 }
187 time.Sleep(20 * time.Millisecond)
188 if err := c.Roundtrip(); err != nil {
189 t.Fatal(err)
190 }
191 }
192
193 // Repeat-only: one repeat_info, no keymap.
194 commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
195 sync()
196 if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
197 t.Fatalf("repeat-only events %v", proxy.opcodes)
198 }
199
200 // New keymap: keymap then repeat_info, and the new content.
201 proxy.opcodes = nil
202 next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
203 commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
204 sync()
205 want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
206 if !slices.Equal(proxy.opcodes, want) {
207 t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
208 }
209 if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
210 t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
211 }
212 // A keyboard created afterwards gets the new keymap.
213 id2 := c.AllocateID()
214 late := &eventsProxy{}
215 late.SetID(id2)
216 registerWireProxy(c, late)
217 requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
218 if err := c.Roundtrip(); err != nil {
219 t.Fatal(err)
220 }
221 if len(late.keymaps) != 1 || late.keymaps[0] != next {
222 t.Fatal("late keyboard got the old keymap")
223 }
224}
225
226func TestFootKeyboard(t *testing.T) {
227 tool, err := exec.LookPath("foot")
228 if err != nil {
229 t.Skip("foot not installed")
230 }
231 s, events, commands, dir := keyboardServer(t)
232 _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "WAYLAND_DEBUG=client"}, "-c", "/dev/null", "sh", "-c", "sleep 10")
233 w := mapped(t, events, 10*time.Second)
234 commands <- ports.FocusWindow{ID: w.ID}
235 for _, code := range []uint32{18, 46, 35, 24, 57, 35, 23, 28} {
236 commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Pressed: true, Time: 1 * time.Millisecond}}
237 commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Time: 2 * time.Millisecond}}
238 }
239 enter := regexp.MustCompile(`wl_keyboard#[0-9]+\.enter\(`)
240 key := regexp.MustCompile(`wl_keyboard#[0-9]+\.key\(`)
241 deadline := time.After(4 * time.Second)
242 tick := time.NewTicker(10 * time.Millisecond)
243 defer tick.Stop()
244 for {
245 select {
246 case err := <-done:
247 t.Fatalf("foot exited: %v: %s", err, output.String())
248 case <-deadline:
249 t.Fatalf("keyboard events missing: %s", output.String())
250 case <-tick.C:
251 text := output.String()
252 if enter.MatchString(text) && key.MatchString(text) {
253 return
254 }
255 }
256 }
257}
258
259// SetModifiers sends the requested depressed mask with the real latched and
260// locked state, and Restore sends the real state back; the seat's own state
261// is untouched. A window that is not focused is ignored.
262func TestSetModifiersOverrideAndRestore(t *testing.T) {
263 s, events, commands, dir := keyboardServer(t)
264 target, proxy := focusedTarget(t, s, events, commands, dir)
265 const capsLocked = 2
266 var id ports.WindowID
267 set := make(chan struct{})
268 s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269 <-set
270 // A marker command applied on the display means every command sent before
271 // it was applied (they apply in order); the roundtrip then delivers the
272 // events they sent.
273 var pending bool
274 wait := func() {
275 t.Helper()
276 pending = !pending
277 commands <- ports.SlotsPending{Pending: pending}
278 deadline := time.Now().Add(2 * time.Second)
279 for {
280 var now bool
281 if !s.display.Do(func() { now = s.slotsPending }) {
282 t.Fatal("display stopped")
283 }
284 if now == pending {
285 break
286 }
287 if time.Now().After(deadline) {
288 t.Fatal("commands not applied")
289 }
290 time.Sleep(time.Millisecond)
291 }
292 roundtrip(t, target)
293 }
294 proxy.depress, proxy.locked = nil, nil
295 commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296 wait()
297 if len(proxy.depress) != 0 {
298 t.Fatalf("unfocused window got modifiers %v", proxy.depress)
299 }
300 commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
301 wait()
302 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
303 t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
304 }
305 done := make(chan ports.ModState, 1)
306 commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
307 wait()
308 if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
309 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
310 }
311 s.display.Do(func() { done <- s.seat.modState })
312 if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
313 t.Fatalf("seat state changed: %+v", m)
314 }
315}
316
317// Restore carries the real state core saw, which swallowed keys (a latch a
318// hint label key consumed) never gave the seat: it is sent and adopted.
319func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
320 s, events, commands, dir := keyboardServer(t)
321 target, proxy := focusedTarget(t, s, events, commands, dir)
322 const shiftLatched, capsLocked = 1, 2
323 var id ports.WindowID
324 set := make(chan struct{})
325 s.display.Do(func() { s.seat.modState = ports.ModState{Latched: shiftLatched}; id = s.seat.focused; close(set) })
326 <-set
327 proxy.depress, proxy.locked = nil, nil
328 want := ports.ModState{Locked: capsLocked} // the latch was consumed
329 commands <- ports.SetModifiers{ID: id, Restore: true, State: want}
330 var got ports.ModState
331 deadline := time.Now().Add(2 * time.Second)
332 for got != want {
333 if time.Now().After(deadline) {
334 t.Fatalf("seat state %+v, want %+v", got, want)
335 }
336 s.display.Do(func() { got = s.seat.modState })
337 time.Sleep(time.Millisecond)
338 }
339 roundtrip(t, target)
340 if !slices.Equal(proxy.locked, []uint32{capsLocked}) || !slices.Equal(proxy.depress, []uint32{0}) {
341 t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342 }
343}