#21 WIP: feat(xwayland): add built-in Xwayland launcher

state
draft
branch
feat/xwayland-builtin → main
opened
bnema ·
updated
CommitSubjectAuthorDateSignatureStatus
8545a93Merge branch 'fix/hdr-sdr-plane' into feat/xwayland-builtinbriceGPG Verified
8342bf4Merge branch 'fix/x11-fullscreen-restore' into feat/xwayland-builtinbriceGPG Verified
7f7b429fix(drm): compose SDR buffers on HDR outputs until the plane pipeline is verifiedbriceGPG Verified
61f9ab3fix(core): restore fullscreen when focus returns to a window that left itbriceGPG Verified
41da69cfix(core): honour the fullscreen state a window maps withbriceGPG Verified
5eb9885test(xwm): make sinkPeer deterministicbriceGPG Verified
13ff6acfix(xwm): announce scale 1 to X11 clients while Xwayland runs at logical sizebriceGPG Verified
f2f4074test(xwm): measure the Wayland-to-X alloc path through onSelectionRequestbriceGPG Verified
04b38e5feat(xwm): answer TIMESTAMP and list it in TARGETSbriceGPG Verified
6168668fix(xwm): harden selection transfers on close, late replies and theme truncationbriceGPG Verified
8fb84bfrefactor(xwm): replace addFD/after function seams with a mockery-generated loop interfacebriceGPG Verified
07dab5dfix(xwm): never adopt the XSETTINGS window as a client windowbriceGPG Verified
a50e31efix(xwm): drop stale TARGETS replies after Wayland takes ownershipbriceGPG Verified
4afca7adocs(xwayland): clipboard bridge, XSETTINGS and root cursorbriceGPG Verified
def433btest(app): e2e clipboard bridge, XSETTINGS and root cursor on the built-in XWMbriceGPG Verified
3e19081Merge branch 'feat/x10' into feat/xwayland-builtinbriceGPG Verified
d7ef3dbMerge branch 'feat/x9' into feat/xwayland-builtinbriceGPG Verified
9bbf554feat(xwm): XSETTINGS manager, xwayland.xsettings key and root cursorbriceGPG Verified
46fd2a1Merge branch 'feat/x8' into feat/xwayland-builtinbriceGPG Verified
1f67695feat(wayland): Wayland to X text selection with INCR and X-owned clipboard sourcesbriceGPG Verified
3df83c3docs(xwayland): describe fullscreen, keyboard grab, output hint and steam_app rulesbriceGPG Verified
8c66911feat(xwm): X to Wayland text selection transfers with INCR, buffer pool and timeoutsbriceGPG Verified
ad55fe8feat(xwm): host timer seam After for selection timeoutsbriceGPG Verified
41edcc9feat(xwm): selection window, XFixes ownership events and TARGETS probebriceGPG Verified
d71e4a4test(app): cover X11 fullscreen, keyboard grab, output hint and class rules end to endbriceGPG Verified
43a1ce7test(wayland): wait for the output change before counting surface entersbriceGPG Verified
1b0bfc7fix(core): keep rule workspaces ahead of the X11 output hint and read the keyboard grab once per syncbriceGPG Verified
56a0abdMerge branch 'feat/x8-pointer' into feat/x8briceGPG Verified
b77ab13Merge branch 'feat/x8-grab' into feat/x8briceGPG Verified
224cf30feat(xwm): place output-sized X11 windows on their output, read bypass hint, steam_app rulesbriceGPG Verified
c5b7312docs(xwayland): state that builtin shows menus, popups and tooltipsbriceGPG Verified
864c005test(wayland): cover X11 pointer lock, confine, relative motion and single-output enterbriceGPG Verified
65eceabfeat(wayland): serve zwp_xwayland_keyboard_grab as a fullscreen shortcuts inhibitorbriceGPG Verified
47739datest(app): cover override-redirect X11 windows end to endbriceGPG Verified
dc722bbrefactor(xwm): share one Space with the wayland server and drop dead codebriceGPG Verified
8ab1d07fix(core): clear pointer and grab when an unmanaged window unmapsbriceGPG Verified
b3f6b3afeat(wayland): host override-redirect X11 windows as unmanaged windowsbriceGPG Verified
27b8b9eMerge branch 'feat/x7-xdgoutput' into feat/x7briceGPG Verified
83719a6Merge branch 'feat/x7-core' into feat/x7briceGPG Verified
7ef926ffeat(xwm): convert Place through Space and track override-redirect windowsbriceGPG Verified
5a832c8feat(xwm): add Space mapping layout coordinates to X coordinatesbriceGPG Verified
305c2c8feat(wayland): private X-space xdg-output manager for XwaylandbriceGPG Verified
bf24250feat(core): place, draw and hit-test unmanaged X11 windowsbriceGPG Verified
58a7346refactor(xwm): log counters on close, drop the test-only Slot lookup and state atoms in present tensebriceGPG Verified
80258b6docs(xwayland): describe the built-in window manager and the xwayland e2e checkbriceGPG Verified
b64a207test(app): add headless Xwayland end-to-end test with an x11probe clientbriceGPG Verified
ace7dd1fix(wayland): forward X11 pointer constraints and modal changes, remap on surface replacement, clear dangling parentsbriceGPG Verified
24f47ebfeat(wayland): integrate the built-in XWM with the serverbriceGPG Verified
e1de5a5test(xwm): replace the sinkPeer sleep with a peer lock barrierbriceGPG Verified
9439437fix(xwm): stop _NET_WM_STATE feedback, honour pre-ready geometry, drop stale map repliesbriceGPG Verified
3ccf649Merge branch 'feat/x6-configure' into feat/x6-xwmbriceGPG Verified
ce78abcfeat(xwm): add map flow, property decoding, serial pairing and reparent handlingbriceGPG Verified
5021c6ctest(xwm): fix staticcheck findings in configure testsbriceGPG Verified
50a2e95docs(xwm): state that Boot takes a component-scoped loggerbriceGPG Verified
f1a2966feat(xwm): add configure, focus, close and stacking lists for X11 windowsbriceGPG Verified
01f9a3dfix(xwm): check root ownership before claiming WM state and never adopt the check windowbriceGPG Verified
69b4382feat(xwm): add window table, boot and fd flush skeleton for the built-in X11 window managerbriceGPG Verified
3ed03aabuild(deps): add bnema.dev/xturbo v0.1.0briceGPG Verified
7646086Merge branch 'feat/x6-place' into feat/xwayland-builtinbriceGPG Verified
aa24d35Merge branch 'feat/x6-window-role' into feat/xwayland-builtinbriceGPG Verified
4eada3btest(xwayland): assert AttachWM is skipped when Xwayland exits before readinessbriceGPG Verified
04fea8dtest(vulkan): skip purego allocation budgets under the race runtimebriceGPG Verified
cac0ab6feat(core): send PlaceWindow for positioned windows on position changebriceGPG Verified
957ec9crefactor(wayland): route window access through role-neutral accessorsbriceGPG Verified
57e2ff1refactor(xwayland): split attachment into Wayland fd at start and WM fd after readinessbriceGPG Verified
db0a693docs(xwayland): state that the built-in backend does not manage X11 windowsbriceGPG Verified
e519f1fbuild(deps): bump purego-libwayland to v0.12.0 and purego to v0.13.0-bnema.2briceGPG Verified
a97cce0docs(xwayland): describe the built-in backend and drop the launcher handoff notebriceGPG Verified
e82a03efeat(xwayland): add experimental built-in launcher and client attachmentbriceGPG Verified

Checks 1

note: a failing or unfinished check blocks the merge.

107 files · +18450 -160
1/1
M .mockery.yml +30 -0
M Makefile +9 -2
M README.md +1 -1
M docs/config.md +9 -3
M docs/desktop.md +12 -1
M docs/dev/headless.md +25 -0
M go.mod +4 -3
M go.sum +8 -6
M internal/adapters/config/config.go +18 -4
M internal/adapters/config/config_test.go +36 -4
M internal/adapters/drm/colorop_match.go +11 -0
A internal/adapters/drm/sdrplane_test.go +30 -0
M internal/adapters/vulkan/capture_test.go +3 -0
A internal/adapters/vulkan/norace_test.go +5 -0
A internal/adapters/vulkan/race_test.go +8 -0
M internal/adapters/vulkan/scene_perf_test.go +6 -0
M internal/adapters/wayland/activation.go +3 -3
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
M internal/adapters/wayland/clipboard.go +5 -0
A internal/adapters/wayland/clipboard_xwm.go +93 -0
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
M internal/adapters/wayland/constraints.go +14 -13
M internal/adapters/wayland/decoration.go +1 -1
M internal/adapters/wayland/dialog.go +1 -1
M internal/adapters/wayland/dmabuf.go +3 -2
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
M internal/adapters/wayland/foreigntoplevel.go +6 -6
M internal/adapters/wayland/frames.go +10 -6
M internal/adapters/wayland/globals.go +11 -1
M internal/adapters/wayland/imagecopy.go +1 -1
M internal/adapters/wayland/inhibit.go +5 -0
M internal/adapters/wayland/outputs.go +72 -1
M internal/adapters/wayland/popup.go +2 -2
M internal/adapters/wayland/scale.go +5 -3
M internal/adapters/wayland/screencast.go +1 -1
M internal/adapters/wayland/seat.go +11 -9
M internal/adapters/wayland/server.go +51 -16
M internal/adapters/wayland/surface.go +15 -2
A internal/adapters/wayland/window.go +112 -0
M internal/adapters/wayland/wire_client_test.go +6 -0
A internal/adapters/wayland/x11override_test.go +107 -0
A internal/adapters/wayland/x11peer_test.go +332 -0
A internal/adapters/wayland/x11window.go +458 -0
A internal/adapters/wayland/x11window_test.go +627 -0
M internal/adapters/wayland/xdg.go +32 -21
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
A internal/adapters/wayland/xwayland.go +75 -0
A internal/adapters/wayland/xwayland_test.go +84 -0
A internal/adapters/wayland/xwaylandgrab.go +90 -0
A internal/adapters/wayland/xwm/atoms.go +118 -0
A internal/adapters/wayland/xwm/configure.go +317 -0
A internal/adapters/wayland/xwm/configure_test.go +591 -0
A internal/adapters/wayland/xwm/cursor.go +109 -0
A internal/adapters/wayland/xwm/cursor_test.go +86 -0
A internal/adapters/wayland/xwm/events.go +303 -0
A internal/adapters/wayland/xwm/fdsource_mock_test.go +129 -0
A internal/adapters/wayland/xwm/fixes_test.go +230 -0
A internal/adapters/wayland/xwm/focus.go +218 -0
A internal/adapters/wayland/xwm/host.go +82 -0
A internal/adapters/wayland/xwm/host_mock_test.go +775 -0
A internal/adapters/wayland/xwm/loop_mock_test.go +180 -0
A internal/adapters/wayland/xwm/mapflow.go +359 -0
A internal/adapters/wayland/xwm/mapflow_test.go +806 -0
A internal/adapters/wayland/xwm/props.go +376 -0
A internal/adapters/wayland/xwm/selection.go +1050 -0
A internal/adapters/wayland/xwm/selection_test.go +801 -0
A internal/adapters/wayland/xwm/selection_xsettings.go +198 -0
A internal/adapters/wayland/xwm/space.go +158 -0
A internal/adapters/wayland/xwm/space_test.go +110 -0
A internal/adapters/wayland/xwm/streams_test.go +197 -0
A internal/adapters/wayland/xwm/table.go +242 -0
A internal/adapters/wayland/xwm/table_test.go +138 -0
A internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +950 -0
A internal/adapters/wayland/xwm/testdata/streams/steady.hex +2150 -0
A internal/adapters/wayland/xwm/unmanaged.go +109 -0
A internal/adapters/wayland/xwm/unmanaged_test.go +247 -0
@@ -0,0 +1,247 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func layoutOf(os ...ports.OutputPlacement) ports.Layout { return ports.Layout(os) }
14+
15+func rootConfigureNotify(win uint32, x, y int16, w, h uint16) xturbo.Event {
16+ ev := xturbo.Event{Kind: xproto.KindConfigureNotify}
17+ binary.LittleEndian.PutUint32(ev.Raw[4:], peerRoot)
18+ binary.LittleEndian.PutUint32(ev.Raw[8:], win)
19+ binary.LittleEndian.PutUint16(ev.Raw[16:], uint16(x))
20+ binary.LittleEndian.PutUint16(ev.Raw[18:], uint16(y))
21+ binary.LittleEndian.PutUint16(ev.Raw[20:], w)
22+ binary.LittleEndian.PutUint16(ev.Raw[22:], h)
23+ return ev
24+}
25+
26+func (p *xpeer) rootConfigureNotify(win uint32, x, y int16, w, h uint16) {
27+ p.event(22, func(b *[32]byte) {
28+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
29+ binary.LittleEndian.PutUint32(b[8:], win)
30+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
31+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
32+ binary.LittleEndian.PutUint16(b[20:], w)
33+ binary.LittleEndian.PutUint16(b[22:], h)
34+ })
35+}
36+
37+func TestPlaceConvertsLayoutToXCoordinates(t *testing.T) {
38+ x, p, h, _, _ := attached(t)
39+ s := newWin(t, x, p, h, 0x500001)
40+ x.SetLayout(layoutOf(out(-640, 0, 640, 480), out(0, 0, 1920, 1080)))
41+ n := settle(t, x, p)
42+
43+ x.Place(s, -640+10, 20)
44+ settle(t, x, p)
45+ cw := since(p, n, opConfigureWindow)
46+ require.Len(t, cw, 1)
47+ require.EqualValues(t, 10, cw[0].u32(12))
48+ require.EqualValues(t, 20, cw[0].u32(16))
49+}
50+
51+func TestSetLayoutRepositionsPlacedWindows(t *testing.T) {
52+ x, p, h, _, _ := attached(t)
53+ s := newWin(t, x, p, h, 0x500001)
54+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
55+ x.Place(s, 100, 50)
56+ n := settle(t, x, p)
57+
58+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080))) // unchanged: nothing resent
59+ settle(t, x, p)
60+ require.Empty(t, since(p, n, opConfigureWindow))
61+
62+ x.SetLayout(layoutOf(out(-100, 0, 100, 100), out(0, 0, 1920, 1080)))
63+ settle(t, x, p)
64+ cw := since(p, n, opConfigureWindow)
65+ require.Len(t, cw, 1)
66+ require.EqualValues(t, 200, cw[0].u32(12))
67+ require.EqualValues(t, 50, cw[0].u32(16))
68+}
69+
70+func TestSetLayoutWarnsOnceWhenOutputsDropped(t *testing.T) {
71+ x, _, _, _, _ := attached(t)
72+ var l ports.Layout
73+ for i := 0; i < maxRegions+2; i++ {
74+ l = append(l, out(i*100, 0, 100, 100))
75+ }
76+ x.SetLayout(l)
77+ require.True(t, x.warnedSpace)
78+ require.Equal(t, 2, x.space.Dropped())
79+}
80+
81+// overrideWindow creates and maps an override window, answers its type read
82+// and pairs it; the mocked host must expect OverrideMapped beforehand.
83+func overrideWindow(t *testing.T, x *XWM, p *xpeer, win uint32, typ string) Slot {
84+ t.Helper()
85+ if typ != "" {
86+ p.setProp(win, p.atom("_NET_WM_WINDOW_TYPE"), uint32(xproto.AtomAtom), 32, le32(p.atom(typ)))
87+ }
88+ p.createNotify(win, true)
89+ readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 })
90+ s := x.tab.lookup(win)
91+ x.feed(t, p, func() { p.rootConfigureNotify(win, 30, 40, 100, 50) })
92+ x.feed(t, p, func() { p.mapNotify(win) })
93+ return s
94+}
95+
96+func TestUnmanagedMapReadsTypeThenPairsThenReports(t *testing.T) {
97+ x, p, h, _, _ := attached(t)
98+ x.SetLayout(layoutOf(out(-640, 0, 640, 480), out(0, 0, 1920, 1080)))
99+ const win = 0x500009
100+ s := overrideWindow(t, x, p, win, "_NET_WM_WINDOW_TYPE_TOOLTIP")
101+ x.flush()
102+ readUntil(t, x, func() bool { return x.tab.rec(s).ready })
103+ typeReads := 0
104+ for _, q := range p.requests() {
105+ if q.major == opGetProperty && q.u32(4) == win {
106+ typeReads++
107+ }
108+ }
109+ require.Equal(t, 1, typeReads)
110+
111+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), -640+30, 40, 100, 50, true).Once()
112+ x.SetSerial(7, 9)
113+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
114+ require.True(t, x.tab.rec(s).ovPaired)
115+}
116+
117+func TestUnmanagedDefaultTypeTakesInput(t *testing.T) {
118+ x, p, h, _, _ := attached(t)
119+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
120+ const win = 0x500009
121+ s := overrideWindow(t, x, p, win, "_NET_WM_WINDOW_TYPE_POPUP_MENU")
122+ x.SetSerial(7, 9)
123+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
124+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
125+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
126+}
127+
128+func TestUnmanagedMoveAndLayoutChangeAndUnmap(t *testing.T) {
129+ x, p, h, _, _ := attached(t)
130+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
131+ const win = 0x500009
132+ s := overrideWindow(t, x, p, win, "")
133+ x.SetSerial(7, 9)
134+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
135+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
136+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
137+
138+ h.EXPECT().OverrideMoved(s, 35, 45, 100, 60).Once()
139+ x.feed(t, p, func() { p.rootConfigureNotify(win, 35, 45, 100, 60) })
140+ x.flush()
141+ x.feed(t, p, func() { p.rootConfigureNotify(win, 35, 45, 100, 60) }) // same rect: no call
142+ x.flush()
143+
144+ h.EXPECT().OverrideMoved(s, -65, 45, 100, 60).Once() // the layout origin moved: same X point, new layout point
145+ x.SetLayout(layoutOf(out(-100, 0, 100, 100), out(0, 0, 1920, 1080)))
146+ x.flush()
147+
148+ h.EXPECT().OverrideGone(s).Once()
149+ x.feed(t, p, func() { p.unmapNotify(win, false) })
150+ require.False(t, x.tab.rec(s).ovPaired)
151+}
152+
153+func TestUnmanagedDestroyReportsGone(t *testing.T) {
154+ x, p, h, _, _ := attached(t)
155+ const win = 0x500009
156+ s := overrideWindow(t, x, p, win, "")
157+ x.SetSerial(7, 9)
158+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
159+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
160+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
161+ h.EXPECT().OverrideGone(s).Once()
162+ x.feed(t, p, func() { p.destroyNotify(win) })
163+ require.Zero(t, x.tab.lookup(win))
164+}
165+
166+func TestFocusInIgnoresUnmanagedAndGrabModes(t *testing.T) {
167+ x, p, h, _, _ := attached(t)
168+ sa := newWin(t, x, p, h, 0x500001)
169+ newWin(t, x, p, h, 0x500002)
170+ x.Focus(sa)
171+ p.createNotify(0x500009, true)
172+ readUntil(t, x, func() bool { return x.tab.lookup(0x500009) != 0 })
173+ n := settle(t, x, p)
174+
175+ focusIn := func(win uint32, mode byte) {
176+ p.event(9, func(e *[32]byte) {
177+ binary.LittleEndian.PutUint32(e[4:], win)
178+ e[8] = mode
179+ })
180+ }
181+ x.feed(t, p, func() { focusIn(0x500009, 0) })
182+ x.feed(t, p, func() { focusIn(0x500002, byte(xproto.NotifyModeGrab)) })
183+ x.feed(t, p, func() { focusIn(0x500002, byte(xproto.NotifyModeUngrab)) })
184+ require.False(t, x.refocus)
185+ settle(t, x, p)
186+ require.Empty(t, since(p, n, opSetInputFocus))
187+
188+ x.feed(t, p, func() { focusIn(0x500002, 0) })
189+ settle(t, x, p)
190+ require.Len(t, since(p, n, opSetInputFocus), 1, "a normal FocusIn on another managed window is still denied")
191+}
192+
193+func TestWMChangeStateIgnored(t *testing.T) {
194+ x, p, h, _, _ := attached(t)
195+ newWin(t, x, p, h, 0x500001)
196+ n := settle(t, x, p)
197+ x.feed(t, p, func() { p.clientMessage(0x500001, p.atom("WM_CHANGE_STATE"), 3) })
198+ settle(t, x, p)
199+ require.Empty(t, since(p, n, opConfigureWindow)) // the mocked host sees no call either
200+}
201+
202+func TestUnmanagedGeometryAllocations(t *testing.T) {
203+ x, p, _, _, _ := attached(t)
204+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
205+ s := x.insert(0x500009)
206+ r := x.tab.rec(s)
207+ r.kind, r.mapped, r.ready, r.ovPaired = kindUnmanaged, true, true, true
208+ r.x, r.y, r.w, r.h = 30, 40, 100, 50
209+ r.ov = x.layoutRect(r)
210+ sinkPeer(t, x, p)
211+ a := rootConfigureNotify(0x500009, 31, 41, 100, 50)
212+ b := rootConfigureNotify(0x500009, 30, 40, 100, 50) // back to the reported rect
213+ allocs := testing.AllocsPerRun(1000, func() {
214+ x.onEvent(&a)
215+ x.onEvent(&b)
216+ x.flush()
217+ })
218+ require.Zero(t, allocs)
219+ require.True(t, r.ovPaired)
220+}
221+
222+func TestFocusInUnmanagedAllocations(t *testing.T) {
223+ x, p, _, _, _ := attached(t)
224+ s := x.insert(0x500009)
225+ x.tab.rec(s).kind = kindUnmanaged
226+ sinkPeer(t, x, p)
227+ ev := xturbo.Event{Kind: xproto.KindFocusIn}
228+ binary.LittleEndian.PutUint32(ev.Raw[4:], 0x500009)
229+ grab := ev
230+ grab.Raw[8] = byte(xproto.NotifyModeGrab)
231+ allocs := testing.AllocsPerRun(1000, func() {
232+ x.onEvent(&ev)
233+ x.onEvent(&grab)
234+ x.flush()
235+ })
236+ require.Zero(t, allocs)
237+ require.False(t, x.refocus)
238+}
239+
240+func TestDropWindowClearsQueuedOverride(t *testing.T) {
241+ x, _, _, _, _ := attached(t)
242+ s := x.insert(0x500009)
243+ r := x.tab.rec(s)
244+ r.kind, r.ovQueued = kindUnmanaged, true
245+ x.dropWindow(s)
246+ require.False(t, r.ovQueued)
247+}
A internal/adapters/wayland/xwm/xpeer_test.go +655 -0
@@ -0,0 +1,655 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "os"
7+ "runtime"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "golang.org/x/sys/unix"
14+)
15+
16+// Opcodes the peer understands.
17+const (
18+ opCreateWindow = 1
19+ opChangeWindowAttributes = 2
20+ opGetWindowAttributes = 3
21+ opMapWindow = 8
22+ opDeleteProperty = 19
23+ opConfigureWindow = 12
24+ opGetGeometry = 14
25+ opQueryTree = 15
26+ opInternAtom = 16
27+ opChangeProperty = 18
28+ opGetProperty = 20
29+ opSetSelectionOwner = 22
30+ opGetSelectionOwner = 23
31+ opSendEvent = 25
32+ opSetInputFocus = 42
33+ opGetInputFocus = 43
34+ opQueryExtension = 98
35+ opKillClient = 113
36+)
37+
38+const (
39+ peerRoot = 0x100
40+ peerXIDBase = 0x200000
41+ peerXIDMask = 0x1fffff
42+ peerMaxWords = 0x100000
43+)
44+
45+type xpeerExt struct{ major, firstEvent, firstError uint8 }
46+
47+// xpeerReq is one request the peer received.
48+type xpeerReq struct {
49+ seq uint16
50+ major uint8
51+ minor uint8
52+ raw []byte // whole request, header included
53+}
54+
55+func (r xpeerReq) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
56+
57+type xpeerProp struct {
58+ typ uint32
59+ format uint8
60+ data []byte
61+}
62+
63+type xpeerGeom struct {
64+ x, y int16
65+ w, h, border uint16
66+}
67+
68+// xpeer is a scripted X server speaking the real wire protocol over a
69+// socketpair: setup, extension queries, atoms, properties, geometry, window
70+// tree. It records every request. It is a protocol peer, not a test double of
71+// a project interface.
72+type xpeer struct {
73+ t *testing.T
74+ f *os.File
75+
76+ mu sync.Mutex
77+ seq uint16
78+ reqs []xpeerReq
79+ exts map[string]xpeerExt
80+ atoms map[string]uint32
81+ nextAtom uint32
82+ props map[uint32]map[uint32]xpeerProp
83+ geoms map[uint32]xpeerGeom
84+ attrs map[uint32][2]byte // window -> {overrideRedirect, mapState}
85+ children []uint32
86+ selOwner map[uint32]uint32 // GetSelectionOwner answers, by selection atom
87+ noARGB bool // RENDER has no ARGB32 format
88+ denyRoot bool // answer the root event-mask request with BadAccess
89+ pids map[uint32]uint32 // XRes QueryClientIds answers, by window
90+ bad map[uint32]bool // windows whose GetProperty/GetGeometry fail with BadWindow
91+ hold bool // keep replies until release
92+ held [][]byte
93+ tap []byte // everything written to the client, when recording
94+ record bool
95+
96+ gate atomic.Pointer[chan struct{}] // closed = reading allowed
97+ sbuf [4096]byte
98+ parked, reading atomic.Bool // peer state: at the closed gate / blocked in the header read
99+ sink atomic.Bool // discard requests without recording or allocating
100+ // discarded counts requests fully dropped in sink mode.
101+ discarded atomic.Uint64
102+ done chan struct{}
103+}
104+
105+func fullPeerExts() map[string]xpeerExt {
106+ return map[string]xpeerExt{
107+ "BIG-REQUESTS": {133, 0, 0},
108+ "XC-MISC": {136, 0, 0},
109+ "Composite": {142, 0, 0},
110+ "XFIXES": {138, 87, 140},
111+ "X-Resource": {144, 0, 0},
112+ }
113+}
114+
115+// startXPeer returns the client end of a socketpair, to give to Boot, and the
116+// peer serving the other end.
117+func startXPeer(t *testing.T, exts map[string]xpeerExt) (int, *xpeer) {
118+ t.Helper()
119+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ p := &xpeer{
124+ t: t, f: os.NewFile(uintptr(fds[1]), "xpeer"),
125+ exts: exts, atoms: map[string]uint32{}, nextAtom: 1000,
126+ props: map[uint32]map[uint32]xpeerProp{}, geoms: map[uint32]xpeerGeom{}, attrs: map[uint32][2]byte{},
127+ pids: map[uint32]uint32{}, bad: map[uint32]bool{},
128+ done: make(chan struct{}),
129+ }
130+ open := make(chan struct{})
131+ close(open)
132+ p.gate.Store(&open)
133+ t.Cleanup(func() {
134+ p.resume() // a paused peer would never see the close
135+ p.f.Close()
136+ <-p.done
137+ })
138+ go p.serve()
139+ return fds[0], p
140+}
141+
142+func (p *xpeer) setupReply() []byte {
143+ vendor := []byte("xpeer\x00\x00\x00")
144+ b := make([]byte, 40+len(vendor)+40)
145+ b[0], b[2] = 1, 11
146+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
147+ binary.LittleEndian.PutUint32(b[12:], peerXIDBase)
148+ binary.LittleEndian.PutUint32(b[16:], peerXIDMask)
149+ binary.LittleEndian.PutUint16(b[24:], 5)
150+ b[28] = 1
151+ copy(b[40:], vendor)
152+ scr := b[40+len(vendor):]
153+ binary.LittleEndian.PutUint32(scr, peerRoot)
154+ binary.LittleEndian.PutUint32(scr[32:], 0x22) // root visual
155+ scr[38] = 24 // root depth
156+ return b
157+}
158+
159+func (p *xpeer) serve() {
160+ defer close(p.done)
161+ var hdr [12]byte
162+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
163+ return
164+ }
165+ // Auth name and data are empty (NewConn sends none).
166+ if _, err := p.f.Write(p.setupReply()); err != nil {
167+ return
168+ }
169+ for {
170+ select {
171+ case <-*p.gate.Load():
172+ default:
173+ p.parked.Store(true)
174+ <-*p.gate.Load()
175+ p.parked.Store(false)
176+ }
177+ // The sink decision follows the header read, so a request written
178+ // after sink.Store(true) is always discarded, never recorded.
179+ p.reading.Store(true)
180+ _, err := io.ReadFull(p.f, p.sbuf[:4])
181+ p.reading.Store(false)
182+ if err != nil {
183+ return
184+ }
185+ if p.sink.Load() {
186+ if !p.discardRest() {
187+ return
188+ }
189+ p.discarded.Add(1)
190+ continue
191+ }
192+ var h [4]byte
193+ copy(h[:], p.sbuf[:4])
194+ raw := h[:]
195+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
196+ if n == 0 { // BIG-REQUESTS header
197+ var l [4]byte
198+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
199+ return
200+ }
201+ raw = append(raw, l[:]...)
202+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
203+ }
204+ rest := make([]byte, n-len(raw))
205+ if _, err := io.ReadFull(p.f, rest); err != nil {
206+ return
207+ }
208+ raw = append(append([]byte(nil), raw...), rest...)
209+ p.mu.Lock()
210+ p.seq++
211+ r := xpeerReq{p.seq, h[0], h[1], raw}
212+ p.reqs = append(p.reqs, r)
213+ p.answer(r, raw[len(raw)-len(rest):])
214+ p.mu.Unlock()
215+ }
216+}
217+
218+// discardRest drops the rest of the request whose 4-byte header is in
219+// p.sbuf, without allocating. It reports false when the connection ended.
220+func (p *xpeer) discardRest() bool {
221+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
222+ left := n - 4
223+ if n == 0 {
224+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
225+ return false
226+ }
227+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
228+ }
229+ for left > 0 {
230+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
231+ if err != nil {
232+ return false
233+ }
234+ left -= m
235+ }
236+ return true
237+}
238+
239+// write sends bytes to the client. The caller holds p.mu.
240+func (p *xpeer) write(b []byte) {
241+ if p.record {
242+ p.tap = append(p.tap, b...)
243+ }
244+ _, _ = p.f.Write(b)
245+}
246+
247+func (p *xpeer) reply(data uint8, body []byte) {
248+ words := (max(len(body), 24) - 24 + 3) / 4
249+ b := make([]byte, 32+4*words)
250+ b[0], b[1] = 1, data
251+ binary.LittleEndian.PutUint16(b[2:], p.seq)
252+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
253+ copy(b[8:], body)
254+ if p.hold {
255+ p.held = append(p.held, b)
256+ return
257+ }
258+ p.write(b)
259+}
260+
261+// release sends the held replies, in request order as a real server does.
262+func (p *xpeer) release() {
263+ p.mu.Lock()
264+ defer p.mu.Unlock()
265+ p.hold = false
266+ for _, b := range p.held {
267+ p.write(b)
268+ }
269+ p.held = nil
270+}
271+
272+// holdReplies keeps the replies of the following requests back.
273+func (p *xpeer) holdReplies() {
274+ p.mu.Lock()
275+ p.hold = true
276+ p.mu.Unlock()
277+}
278+
279+func (p *xpeer) errorPacket(code, major uint8, bad uint32) {
280+ b := make([]byte, 32)
281+ b[1] = code
282+ binary.LittleEndian.PutUint16(b[2:], p.seq)
283+ binary.LittleEndian.PutUint32(b[4:], bad)
284+ b[10] = major
285+ p.write(b)
286+}
287+
288+func (p *xpeer) extName(major uint8) string {
289+ for name, e := range p.exts {
290+ if e.major == major {
291+ return name
292+ }
293+ }
294+ return ""
295+}
296+
297+func le32(v ...uint32) []byte {
298+ b := make([]byte, 0, 4*len(v))
299+ for _, x := range v {
300+ b = binary.LittleEndian.AppendUint32(b, x)
301+ }
302+ return b
303+}
304+
305+// answer runs with p.mu held.
306+func (p *xpeer) answer(r xpeerReq, body []byte) {
307+ switch {
308+ case r.major == opQueryExtension:
309+ e, ok := p.exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
310+ var present uint8
311+ if ok {
312+ present = 1
313+ }
314+ p.reply(0, []byte{present, e.major, e.firstEvent, e.firstError})
315+ case r.major == opInternAtom:
316+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
317+ a, ok := p.atoms[name]
318+ if !ok {
319+ p.nextAtom++
320+ a = p.nextAtom
321+ p.atoms[name] = a
322+ }
323+ p.reply(0, le32(a))
324+ case r.major == opGetSelectionOwner:
325+ p.reply(0, le32(p.selOwner[binary.LittleEndian.Uint32(body)]))
326+ case r.major == opGetInputFocus:
327+ p.reply(1, le32(0))
328+ case r.major == opQueryTree:
329+ b := make([]byte, 24+4*len(p.children))
330+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
331+ binary.LittleEndian.PutUint16(b[8:], uint16(len(p.children)))
332+ for i, c := range p.children {
333+ binary.LittleEndian.PutUint32(b[24+4*i:], c)
334+ }
335+ p.reply(0, b)
336+ case r.major == opGetWindowAttributes:
337+ a := p.attrs[binary.LittleEndian.Uint32(body)]
338+ b := make([]byte, 36) // reply length 3
339+ b[18] = a[1] // map_state is byte 26 of the reply
340+ b[19] = a[0] // override_redirect, byte 27
341+ p.reply(0, b)
342+ case r.major == opGetGeometry && p.bad[binary.LittleEndian.Uint32(body)]:
343+ p.errorPacket(9, r.major, binary.LittleEndian.Uint32(body)) // BadDrawable
344+ case r.major == opGetProperty && p.bad[binary.LittleEndian.Uint32(body)]:
345+ p.errorPacket(3, r.major, binary.LittleEndian.Uint32(body)) // BadWindow
346+ case r.major == opConfigureWindow:
347+ g := p.geoms[binary.LittleEndian.Uint32(body)]
348+ mask, vals := binary.LittleEndian.Uint16(body[4:]), body[8:]
349+ for bit, dst := 0, []any{&g.x, &g.y, &g.w, &g.h, &g.border}; bit < 5; bit++ {
350+ if mask&(1<<bit) == 0 {
351+ continue
352+ }
353+ v := uint16(binary.LittleEndian.Uint32(vals))
354+ vals = vals[4:]
355+ switch d := dst[bit].(type) {
356+ case *int16:
357+ *d = int16(v)
358+ case *uint16:
359+ *d = v
360+ }
361+ }
362+ p.geoms[binary.LittleEndian.Uint32(body)] = g
363+ case r.major == opGetGeometry:
364+ g := p.geoms[binary.LittleEndian.Uint32(body)]
365+ b := make([]byte, 24)
366+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
367+ binary.LittleEndian.PutUint16(b[4:], uint16(g.x))
368+ binary.LittleEndian.PutUint16(b[6:], uint16(g.y))
369+ binary.LittleEndian.PutUint16(b[8:], g.w)
370+ binary.LittleEndian.PutUint16(b[10:], g.h)
371+ binary.LittleEndian.PutUint16(b[12:], g.border)
372+ p.reply(24, b)
373+ case r.major == opChangeProperty:
374+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
375+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
376+ if p.props[win] == nil {
377+ p.props[win] = map[uint32]xpeerProp{}
378+ }
379+ p.props[win][prop] = xpeerProp{
380+ typ: binary.LittleEndian.Uint32(body[8:]), format: format,
381+ data: append([]byte(nil), body[20:20+n*int(format)/8]...),
382+ }
383+ case r.major == opDeleteProperty:
384+ delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))
385+ case r.major == opGetProperty:
386+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
387+ off, length := int(binary.LittleEndian.Uint32(body[12:]))*4, int(binary.LittleEndian.Uint32(body[16:]))*4
388+ v, ok := p.props[win][prop]
389+ data, after := v.data, 0
390+ if off > len(data) {
391+ off = len(data)
392+ }
393+ data = data[off:]
394+ if len(data) > length {
395+ data, after = data[:length], len(data)-length
396+ }
397+ b := make([]byte, 24+(len(data)+3)&^3)
398+ if ok && v.format != 0 {
399+ binary.LittleEndian.PutUint32(b[0:], v.typ)
400+ binary.LittleEndian.PutUint32(b[4:], uint32(after))
401+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(v.format)/8)))
402+ copy(b[24:], data)
403+ if r.raw[1] != 0 && after == 0 { // delete
404+ delete(p.props[win], prop)
405+ }
406+ }
407+ p.reply(v.format, b)
408+ case r.major == opChangeWindowAttributes:
409+ const cwEventMask = 1 << 11
410+ if p.denyRoot && binary.LittleEndian.Uint32(body) == peerRoot && binary.LittleEndian.Uint32(body[4:])&cwEventMask != 0 {
411+ p.errorPacket(10, opChangeWindowAttributes, peerRoot) // BadAccess
412+ }
413+ case r.major >= 128:
414+ switch p.extName(r.major) {
415+ case "BIG-REQUESTS":
416+ p.reply(0, le32(peerMaxWords))
417+ case "XC-MISC":
418+ if r.minor == 1 {
419+ p.reply(0, le32(0, 1))
420+ }
421+ case "RENDER":
422+ if r.minor == 1 { // QueryPictFormats: one format (ARGB32), unless noARGB
423+ n := uint32(1)
424+ if p.noARGB {
425+ n = 0
426+ }
427+ b := make([]byte, 24+28*int(n))
428+ binary.LittleEndian.PutUint32(b[0:], n)
429+ if n == 1 {
430+ f := b[24:]
431+ binary.LittleEndian.PutUint32(f[0:], 0x77) // format id
432+ f[4], f[5] = 1, 32 // direct, depth 32
433+ binary.LittleEndian.PutUint16(f[8:], 16) // red shift
434+ binary.LittleEndian.PutUint16(f[10:], 0xff)
435+ binary.LittleEndian.PutUint16(f[20:], 24) // alpha shift
436+ binary.LittleEndian.PutUint16(f[22:], 0xff)
437+ }
438+ p.reply(0, b)
439+ }
440+ case "Composite", "XFIXES":
441+ if r.minor == 0 {
442+ p.reply(0, le32(5, 4))
443+ }
444+ case "X-Resource":
445+ switch r.minor {
446+ case 0:
447+ p.reply(0, []byte{1, 0, 2, 0})
448+ case 4: // QueryClientIds: one spec, answered when a pid is scripted
449+ pid, ok := p.pids[r.u32(8)]
450+ if !ok {
451+ p.reply(0, le32(0))
452+ break
453+ }
454+ b := make([]byte, 40)
455+ binary.LittleEndian.PutUint32(b[0:], 1)
456+ binary.LittleEndian.PutUint32(b[24:], r.u32(8))
457+ binary.LittleEndian.PutUint32(b[28:], 2) // LocalClientPID
458+ binary.LittleEndian.PutUint32(b[32:], 4)
459+ binary.LittleEndian.PutUint32(b[36:], pid)
460+ p.reply(0, b)
461+ }
462+ }
463+ }
464+}
465+
466+// requests returns a snapshot of every request received after setup.
467+func (p *xpeer) requests() []xpeerReq {
468+ p.mu.Lock()
469+ defer p.mu.Unlock()
470+ return append([]xpeerReq(nil), p.reqs...)
471+}
472+
473+// waitReq waits for a request satisfying match among those after the first
474+// skip requests, and returns it with the new count.
475+func (p *xpeer) waitReq(skip int, match func(xpeerReq) bool) (xpeerReq, int) {
476+ p.t.Helper()
477+ deadline := time.Now().Add(5 * time.Second)
478+ for time.Now().Before(deadline) {
479+ reqs := p.requests()
480+ for i := skip; i < len(reqs); i++ {
481+ if match(reqs[i]) {
482+ return reqs[i], i + 1
483+ }
484+ }
485+ time.Sleep(time.Millisecond)
486+ }
487+ p.t.Fatal("xpeer: expected request never arrived")
488+ return xpeerReq{}, 0
489+}
490+
491+func (p *xpeer) atom(name string) uint32 {
492+ p.mu.Lock()
493+ defer p.mu.Unlock()
494+ return p.atoms[name]
495+}
496+
497+func (p *xpeer) prop(win uint32, atom string) (xpeerProp, bool) {
498+ p.mu.Lock()
499+ defer p.mu.Unlock()
500+ v, ok := p.props[win][p.atoms[atom]]
501+ return v, ok
502+}
503+
504+// event sends a 32-byte core event stamped with the current sequence number.
505+func (p *xpeer) event(code uint8, fill func(b *[32]byte)) {
506+ p.mu.Lock()
507+ defer p.mu.Unlock()
508+ var b [32]byte
509+ b[0] = code
510+ binary.LittleEndian.PutUint16(b[2:], p.seq)
511+ if fill != nil {
512+ fill(&b)
513+ }
514+ p.write(b[:])
515+}
516+
517+func (p *xpeer) createNotify(win uint32, override bool) {
518+ p.event(16, func(b *[32]byte) {
519+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
520+ binary.LittleEndian.PutUint32(b[8:], win)
521+ binary.LittleEndian.PutUint16(b[16:], 100)
522+ binary.LittleEndian.PutUint16(b[18:], 50)
523+ if override {
524+ b[22] = 1
525+ }
526+ })
527+}
528+
529+func (p *xpeer) destroyNotify(win uint32) {
530+ p.event(17, func(b *[32]byte) {
531+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
532+ binary.LittleEndian.PutUint32(b[8:], win)
533+ })
534+}
535+
536+// pause closes the gate: the peer parks before reading its next request.
537+func (p *xpeer) pause() {
538+ g := make(chan struct{})
539+ p.gate.Store(&g)
540+}
541+
542+// waitParked waits until the peer goroutine is parked at the closed gate. A
543+// peer blocked in its header read only gets there after one more request, so
544+// the caller must have one in flight (or be sure it is parked already).
545+func (p *xpeer) waitParked() {
546+ p.t.Helper()
547+ for !p.parked.Load() {
548+ runtime.Gosched()
549+ }
550+}
551+
552+// resume lets the peer read again.
553+func (p *xpeer) resume() {
554+ g := *p.gate.Load()
555+ open := make(chan struct{})
556+ close(open)
557+ p.gate.Store(&open)
558+ select {
559+ case <-g:
560+ default:
561+ close(g)
562+ }
563+}
564+
565+// set mutates the peer's script under its lock.
566+func (p *xpeer) set(fn func(p *xpeer)) {
567+ p.mu.Lock()
568+ defer p.mu.Unlock()
569+ fn(p)
570+}
571+
572+// hangup closes the server end; the client reads EOF.
573+func (p *xpeer) hangup() {
574+ if rc, err := p.f.SyscallConn(); err == nil {
575+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
576+ }
577+}
578+
579+// setProp scripts a window property for GetProperty.
580+func (p *xpeer) setProp(win, atom, typ uint32, format uint8, data []byte) {
581+ p.mu.Lock()
582+ defer p.mu.Unlock()
583+ if p.props[win] == nil {
584+ p.props[win] = map[uint32]xpeerProp{}
585+ }
586+ p.props[win][atom] = xpeerProp{typ: typ, format: format, data: data}
587+}
588+
589+func (p *xpeer) mapRequest(win uint32) {
590+ p.event(20, func(b *[32]byte) {
591+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
592+ binary.LittleEndian.PutUint32(b[8:], win)
593+ })
594+}
595+
596+func (p *xpeer) mapNotify(win uint32) {
597+ p.event(19, func(b *[32]byte) {
598+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
599+ binary.LittleEndian.PutUint32(b[8:], win)
600+ })
601+}
602+
603+func (p *xpeer) unmapNotify(win uint32, synthetic bool) {
604+ p.event(18, func(b *[32]byte) {
605+ if synthetic {
606+ b[0] |= 0x80
607+ }
608+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
609+ binary.LittleEndian.PutUint32(b[8:], win)
610+ })
611+}
612+
613+func (p *xpeer) propertyNotify(win, atom uint32, deleted bool) {
614+ p.event(28, func(b *[32]byte) {
615+ binary.LittleEndian.PutUint32(b[4:], win)
616+ binary.LittleEndian.PutUint32(b[8:], atom)
617+ if deleted {
618+ b[16] = 1
619+ }
620+ })
621+}
622+
623+func (p *xpeer) reparentNotify(win, parent uint32) {
624+ p.event(21, func(b *[32]byte) {
625+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
626+ binary.LittleEndian.PutUint32(b[8:], win)
627+ binary.LittleEndian.PutUint32(b[12:], parent)
628+ })
629+}
630+
631+func (p *xpeer) clientMessage(win, typ uint32, data ...uint32) {
632+ p.event(33, func(b *[32]byte) {
633+ b[1] = 32
634+ binary.LittleEndian.PutUint32(b[4:], win)
635+ binary.LittleEndian.PutUint32(b[8:], typ)
636+ for i, d := range data {
637+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
638+ }
639+ })
640+}
641+
642+func (p *xpeer) focusIn(win uint32) {
643+ p.event(9, func(b *[32]byte) { binary.LittleEndian.PutUint32(b[4:], win) })
644+}
645+
646+func (p *xpeer) configureNotify(win uint32) {
647+ p.event(22, func(b *[32]byte) {
648+ binary.LittleEndian.PutUint32(b[4:], win)
649+ binary.LittleEndian.PutUint32(b[8:], win)
650+ binary.LittleEndian.PutUint16(b[20:], 10)
651+ binary.LittleEndian.PutUint16(b[22:], 20)
652+ binary.LittleEndian.PutUint16(b[24:], 300)
653+ binary.LittleEndian.PutUint16(b[26:], 200)
654+ })
655+}
A internal/adapters/wayland/xwm/xsettings_test.go +244 -0
@@ -0,0 +1,244 @@
1+package xwm
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "strings"
7+ "testing"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func bootSettings(t *testing.T, cfg ports.XwaylandSettings, mutate func(p *xpeer)) (*XWM, *xpeer) {
15+ t.Helper()
16+ fd, p := startXPeer(t, fullPeerExts())
17+ if mutate != nil {
18+ p.set(mutate)
19+ }
20+ x, err := BootSettings(context.Background(), fd, testLog(), cfg)
21+ require.NoError(t, err)
22+ t.Cleanup(x.CloseConn)
23+ return x, p
24+}
25+
26+func (p *xpeer) count(major uint8, match func(xpeerReq) bool) (n int) {
27+ for _, r := range p.requests() {
28+ if r.major == major && (match == nil || match(r)) {
29+ n++
30+ }
31+ }
32+ return n
33+}
34+
35+func TestXSettingsEncode(t *testing.T) {
36+ s := &xsettings{serial: 7, theme: "ab", size: 24, themeSerial: 7, sizeSerial: 5, fixedSerial: 3}
37+ n := s.encode()
38+ u32 := binary.LittleEndian.AppendUint32
39+ var want []byte
40+ want = append(want, 0, 0, 0, 0)
41+ want = u32(want, 7)
42+ want = u32(want, 5)
43+ // Gdk/UnscaledDPI INT (name length 15 -> pad 16)
44+ want = append(want, 0, 0, 15, 0)
45+ want = append(want, "Gdk/UnscaledDPI"...)
46+ want = append(want, 0)
47+ want = u32(want, 3)
48+ want = u32(want, 96*1024)
49+ // Gdk/WindowScalingFactor INT 1 (length 23 -> pad 24)
50+ want = append(want, 0, 0, 23, 0)
51+ want = append(want, "Gdk/WindowScalingFactor"...)
52+ want = append(want, 0)
53+ want = u32(want, 3)
54+ want = u32(want, 1)
55+ // Gtk/CursorThemeName STRING "ab"
56+ want = append(want, 1, 0, 19, 0)
57+ want = append(want, "Gtk/CursorThemeName"...)
58+ want = append(want, 0) // 19 -> 20
59+ want = u32(want, 7)
60+ want = u32(want, 2)
61+ want = append(want, 'a', 'b', 0, 0)
62+ // Gtk/CursorThemeSize INT 24
63+ want = append(want, 0, 0, 19, 0)
64+ want = append(want, "Gtk/CursorThemeSize"...)
65+ want = append(want, 0)
66+ want = u32(want, 5)
67+ want = u32(want, 24)
68+ // Xft/DPI INT (name length 7 -> pad 1)
69+ want = append(want, 0, 0, 7, 0)
70+ want = append(want, "Xft/DPI"...)
71+ want = append(want, 0)
72+ want = u32(want, 3)
73+ want = u32(want, 96*1024)
74+ require.Equal(t, want, s.buf[:n])
75+ // Name lengths 1..4 mod 4 pad to a multiple of 4.
76+ for l := 1; l <= 8; l++ {
77+ require.Zero(t, pad4(l)%4)
78+ require.GreaterOrEqual(t, pad4(l), l)
79+ require.Less(t, pad4(l)-l, 4)
80+ }
81+}
82+
83+func TestXSettingsOwnership(t *testing.T) {
84+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 32}, nil)
85+ win := x.xs.win
86+ require.NotZero(t, win)
87+ s0, mgr := p.atom("_XSETTINGS_S0"), p.atom("MANAGER")
88+ own := -1
89+ for i, r := range p.requests() {
90+ if r.major == opSetSelectionOwner && r.u32(8) == s0 {
91+ own = i
92+ require.Equal(t, win, r.u32(4))
93+ }
94+ }
95+ require.NotEqual(t, -1, own)
96+ var sent bool
97+ for _, r := range p.requests() {
98+ if r.major == opSendEvent && r.u32(4) == peerRoot && r.u32(20) == mgr {
99+ sent = true
100+ require.Equal(t, s0, r.u32(28))
101+ require.Equal(t, win, r.u32(32))
102+ }
103+ }
104+ require.True(t, sent, "MANAGER client message to the root")
105+ prop, ok := p.prop(win, "_XSETTINGS_SETTINGS")
106+ require.True(t, ok)
107+ require.Equal(t, p.atom("_XSETTINGS_SETTINGS"), prop.typ)
108+ require.EqualValues(t, 8, prop.format)
109+
110+ // SelectionClear stops updates.
111+ require.True(t, x.xs.active)
112+ p.event(29, func(b *[32]byte) {
113+ binary.LittleEndian.PutUint32(b[8:], win)
114+ binary.LittleEndian.PutUint32(b[12:], s0)
115+ })
116+ require.NoError(t, x.conn.Sync(&x.h))
117+ require.False(t, x.xs.active)
118+}
119+
120+func TestXSettingsExistingOwner(t *testing.T) {
121+ var s0 uint32
122+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true}, func(p *xpeer) {
123+ p.selOwner = map[uint32]uint32{}
124+ // atoms are assigned on intern: pre-assign through a known name.
125+ p.nextAtom++
126+ s0 = p.nextAtom
127+ p.atoms["_XSETTINGS_S0"] = s0
128+ p.selOwner[s0] = 0xabc
129+ })
130+ require.False(t, x.xs.active)
131+ require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 }))
132+ _, ok := p.prop(x.xs.win, "_XSETTINGS_SETTINGS")
133+ require.False(t, ok)
134+}
135+
136+func TestXSettingsOff(t *testing.T) {
137+ _, p := bootSettings(t, ports.XwaylandSettings{XSettings: false, CursorSize: 24}, nil)
138+ s0 := p.atom("_XSETTINGS_S0")
139+ require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 && s0 != 0 }))
140+ require.Zero(t, p.count(opGetSelectionOwner, nil))
141+}
142+
143+// decodeSettings returns serial and the per-setting last-change serial by name.
144+func decodeSettings(t *testing.T, b []byte) (uint32, map[string]uint32) {
145+ t.Helper()
146+ le := binary.LittleEndian
147+ out := map[string]uint32{}
148+ n := int(le.Uint32(b[8:]))
149+ off := 12
150+ for i := 0; i < n; i++ {
151+ typ, nl := b[off], int(le.Uint16(b[off+2:]))
152+ off += 4
153+ name := string(b[off : off+nl])
154+ off += pad4(nl)
155+ out[name] = le.Uint32(b[off:])
156+ off += 4
157+ if typ == xsString {
158+ off += 4 + pad4(int(le.Uint32(b[off:])))
159+ } else {
160+ off += 4
161+ }
162+ }
163+ return le.Uint32(b[4:]), out
164+}
165+
166+func TestXSettingsSerial(t *testing.T) {
167+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorSize: 24}, nil)
168+ win := x.xs.win
169+ props := func() int {
170+ return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win })
171+ }
172+ require.Equal(t, 1, props())
173+ // Layout changes (any scale) and identical settings write nothing.
174+ for _, scale := range []float64{1, 2} {
175+ var sp Space
176+ sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}})
177+ x.space = &sp
178+ x.LayoutChanged(true)
179+ require.NoError(t, x.conn.Flush())
180+ require.NoError(t, x.conn.Sync(&x.h))
181+ require.Equal(t, 1, props(), "scale %v", scale)
182+ }
183+ prop, _ := p.prop(win, "_XSETTINGS_SETTINGS")
184+ serial, last := decodeSettings(t, prop.data)
185+ require.EqualValues(t, 1, serial)
186+ require.EqualValues(t, 1, last["Xft/DPI"])
187+ require.EqualValues(t, 1, last["Gdk/WindowScalingFactor"])
188+ require.EqualValues(t, 1, last["Gdk/UnscaledDPI"])
189+ require.EqualValues(t, 96*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:])), "Xft/DPI is last")
190+
191+ // A changed cursor size republishes: only that setting's serial moves.
192+ x.cfg.CursorSize = 32
193+ require.True(t, x.publishXSettings(false))
194+ require.NoError(t, x.conn.Flush())
195+ require.NoError(t, x.conn.Sync(&x.h))
196+ prop, _ = p.prop(win, "_XSETTINGS_SETTINGS")
197+ serial, last = decodeSettings(t, prop.data)
198+ require.EqualValues(t, 2, serial)
199+ require.EqualValues(t, 2, last["Gtk/CursorThemeSize"])
200+ require.EqualValues(t, 1, last["Xft/DPI"])
201+ require.False(t, x.publishXSettings(false), "identical settings write nothing")
202+
203+ x.xs.serial = 0xffffffff
204+ x.xs.size = 0
205+ x.publishXSettings(false)
206+ require.EqualValues(t, 0, x.xs.serial, "wraps")
207+}
208+
209+func TestXSettingsNoChangeAllocations(t *testing.T) {
210+ x, _ := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 24}, nil)
211+ var sp Space
212+ sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}})
213+ x.space = &sp
214+ x.LayoutChanged(true)
215+ l := ports.Layout{{Width: 100, Height: 100, Scale: 2}}
216+ n := testing.AllocsPerRun(100, func() {
217+ sp.Set(l)
218+ x.LayoutChanged(false)
219+ x.publishXSettings(false)
220+ })
221+ require.Zero(t, n)
222+}
223+
224+// The XSETTINGS manager window is never adopted as a client window.
225+func TestXSettingsWindowNotAdopted(t *testing.T) {
226+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true}, nil)
227+ h := newMockhost(t)
228+ post, _ := newPost()
229+ x.attach(newMockfdSource(t), h, post)
230+ require.NotZero(t, x.xs.win)
231+ p.createNotify(x.xs.win, false)
232+ ev := x.Stats().Events
233+ readUntil(t, x, func() bool { return x.Stats().Events > ev })
234+ x.flush()
235+ require.Zero(t, x.tab.lookup(x.xs.win))
236+}
237+
238+func TestCursorThemeNameRuneBoundary(t *testing.T) {
239+ x := &XWM{}
240+ x.cfg.CursorTheme = strings.Repeat("a", xsMaxString-1) + "é" // é straddles the limit
241+ got := x.cursorThemeName()
242+ require.True(t, utf8.ValidString(got))
243+ require.Equal(t, strings.Repeat("a", xsMaxString-1), got)
244+}
A internal/adapters/wayland/xwm/xwm.go +509 -0
@@ -0,0 +1,509 @@
1+// Package xwm is the built-in X11 window manager for Xwayland: it speaks the
2+// X protocol over the -wm socket with xturbo and reports managed windows to
3+// the Wayland server through the unexported host interface.
4+//
5+// Threading: Boot runs on the starter goroutine and blocks. The XWM it returns
6+// is then handed to the display goroutine, which owns it exclusively; nothing
7+// in this package is locked.
8+package xwm
9+
10+import (
11+ "context"
12+ "encoding/binary"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "os"
17+ "time"
18+
19+ "bnema.dev/xturbo"
20+ "bnema.dev/xturbo/composite"
21+ "bnema.dev/xturbo/res"
22+ "bnema.dev/xturbo/xfixes"
23+ "bnema.dev/xturbo/xproto"
24+ "github.com/bnema/purego-libwayland/server"
25+ "github.com/bnema/zerowrap"
26+ "golang.org/x/sys/unix"
27+
28+ "github.com/bnema/neferwl/internal/ports"
29+)
30+
31+var (
32+ // ErrWMExists is returned by Boot when another client already redirects
33+ // the root window's children.
34+ ErrWMExists = errors.New("xwm: another window manager owns the root window")
35+ // ErrNoComposite is returned by Boot when the X server lacks Composite.
36+ ErrNoComposite = errors.New("xwm: X server has no Composite extension")
37+)
38+
39+// Stats are connection and event counters, for diagnostics.
40+type Stats struct {
41+ Events, Replies, Errors, Unattributed, Stale, RingFull, LegacyID, UnmanagedSeen uint64
42+}
43+
44+// Version request ids, carried in the first Expect argument of kindVersion.
45+const (
46+ versionComposite uint32 = iota + 1
47+ versionXFixes
48+ versionRes
49+)
50+
51+const unpairedCap = 64
52+
53+// XWM manages the windows of one Xwayland. Display goroutine only, after Boot.
54+type XWM struct {
55+ conn *xturbo.Conn
56+ log zerowrap.Logger
57+ root uint32
58+ wmWin uint32
59+ selWin uint32 // selection bridge window (selection.go)
60+ lastTime uint32 // server time of the latest timestamped X event
61+ sel selBridge
62+ atoms [atomCount]uint32
63+ tab *table
64+ h xturbo.Handler
65+ stats Stats
66+
67+ // attachment
68+ host host
69+ src fdSource
70+ lp loop // display fd sources and timers
71+ post func(func())
72+ flushFn func()
73+
74+ posted, writable, closed bool
75+ bootErr error
76+ warnedRing, warnedFull bool
77+ warnedUnpaired bool
78+
79+ scratch []byte // encode buffer for 32-bit property values, 4 bytes per slot
80+
81+ // Shared by the map, configure and focus flows.
82+ dirty []Slot // slots with a pending configure, capacity == table size
83+ clients []uint32 // _NET_CLIENT_LIST, map order
84+ stacking []uint32 // _NET_CLIENT_LIST_STACKING, bottom to top
85+ active Slot // slot named by _NET_ACTIVE_WINDOW, 0 = none
86+ refocus bool // a FocusIn was denied; focus is re-issued in flush
87+ listsDirty uint8 // client lists to rewrite in flush
88+ unpaired [unpairedCap]struct{ serial, surface uint64 }
89+ unpairedN int
90+
91+ cfg ports.XwaylandSettings
92+ xs xsettings // XSETTINGS manager and root cursor (xsettings.go, cursor.go)
93+
94+ space *Space // layout <-> X coordinates, shared with the host
95+ warnedSpace bool // more outputs than the Space holds, logged once
96+ ovDirty []Slot // mapped unmanaged slots whose geometry changed, capacity == table size
97+}
98+
99+func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
100+ x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}
101+ x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
102+ x.fit()
103+ x.initSelection()
104+ return x
105+}
106+
107+// fit sizes the per-slot buffers to the table; called after every insert.
108+func (x *XWM) fit() {
109+ n := len(x.tab.recs)
110+ if cap(x.dirty) < n {
111+ x.dirty = append(make([]Slot, 0, n), x.dirty...)
112+ x.clients = append(make([]uint32, 0, n), x.clients...)
113+ x.ovDirty = append(make([]Slot, 0, n), x.ovDirty...)
114+ x.stacking = append(make([]uint32, 0, n), x.stacking...)
115+ x.scratch = make([]byte, 0, 4*n)
116+ }
117+}
118+
119+// insert adds a record for xid; 0 means the table is full.
120+func (x *XWM) insert(xid uint32) Slot {
121+ s := x.tab.insert(xid)
122+ if s == 0 {
123+ if !x.warnedFull {
124+ x.warnedFull = true
125+ x.log.Warn().Int("windows", x.tab.n).Msg("X11 window table full, window ignored")
126+ }
127+ return 0
128+ }
129+ x.fit()
130+ return s
131+}
132+
133+// expect registers seq, tolerating a full ring: the reply then arrives
134+// unattributed and is dropped.
135+func (x *XWM) expect(seq xturbo.Seq, kind uint16, a0, a1 uint32) {
136+ if seq == 0 {
137+ return
138+ }
139+ if err := x.conn.Expect(seq, kind, a0, a1, 0, 0); err != nil {
140+ x.stats.RingFull++
141+ if !x.warnedRing {
142+ x.warnedRing = true
143+ x.log.Warn().Err(err).Msg("X11 request not tracked")
144+ }
145+ }
146+}
147+
148+// setProp32 queues ChangeProperty (replace, format 32) for vals.
149+func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
150+ n := 4 * len(vals)
151+ if cap(x.scratch) < n {
152+ x.scratch = make([]byte, 0, n)
153+ }
154+ p := x.scratch[:n]
155+ for i, v := range vals {
156+ binary.LittleEndian.PutUint32(p[4*i:], v)
157+ }
158+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(win),
159+ xproto.Atom(x.atoms[prop]), typ, 32, uint32(len(vals)), p)
160+}
161+
162+// Boot connects to the X server on wmFD and becomes its window manager. It
163+// blocks (about 25 s at worst) and must not run on the display goroutine: the
164+// handshake needs Xwayland, which needs the display goroutine to serve its
165+// Wayland requests. It takes ownership of wmFD and closes it on failure.
166+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
167+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
168+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
169+ return BootSettings(ctx, wmFD, log, ports.XwaylandSettings{})
170+}
171+
172+// BootSettings is Boot with the XSETTINGS manager and the root cursor of cfg.
173+func BootSettings(ctx context.Context, wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) {
174+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
175+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
176+ // can never reach a descriptor number reused after Boot closed wmFD.
177+ var watch *os.File
178+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
179+ watch = os.NewFile(uintptr(dup), "xwm abort")
180+ }
181+ stopWatch := func() {}
182+ if watch != nil {
183+ quit, joined := make(chan struct{}), make(chan struct{})
184+ go func() {
185+ defer close(joined)
186+ select {
187+ case <-ctx.Done():
188+ if rc, err := watch.SyscallConn(); err == nil {
189+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
190+ }
191+ case <-quit:
192+ }
193+ _ = watch.Close()
194+ }()
195+ stopWatch = func() { close(quit); <-joined }
196+ }
197+ x, err := boot(wmFD, log, cfg)
198+ stopWatch()
199+ if err == nil && ctx.Err() != nil {
200+ x.CloseConn()
201+ x, err = nil, ctx.Err()
202+ }
203+ if err != nil && ctx.Err() != nil {
204+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
205+ }
206+ return x, err
207+}
208+
209+func boot(wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) {
210+ conn, err := xturbo.NewConn(wmFD)
211+ if err != nil {
212+ return nil, fmt.Errorf("xwm: connect: %w", err)
213+ }
214+ x := newXWM(conn, log)
215+ x.cfg = cfg
216+ if err := x.boot(); err != nil {
217+ _ = conn.Close()
218+ return nil, err
219+ }
220+ return x, nil
221+}
222+
223+func (x *XWM) boot() error {
224+ conn := x.conn
225+ ext := conn.Ext()
226+ if !ext.Info(xturbo.ExtComposite).Present {
227+ return ErrNoComposite
228+ }
229+ var visual, depth uint32
230+ if it := xproto.NewSetupIter(conn.SetupBytes(), 1); it.Next() {
231+ if roots := it.Value().Roots(); roots.Next() {
232+ visual, depth = uint32(roots.Value().RootVisual()), uint32(roots.Value().RootDepth())
233+ }
234+ }
235+ x.log.Info().Uint32("root", x.root).Uint32("visual", visual).Uint32("depth", depth).
236+ Bool("xfixes", ext.Info(xturbo.ExtXFixes).Present).
237+ Bool("res", ext.Info(xturbo.ExtRes).Present).Msg("X server")
238+
239+ if err := conn.InternAtoms(atomNames[:], false, x.atoms[:]); err != nil {
240+ return fmt.Errorf("xwm: intern atoms: %w", err)
241+ }
242+ xid, err := conn.NewXID()
243+ if err != nil {
244+ return fmt.Errorf("xwm: allocate window id: %w", err)
245+ }
246+ x.wmWin = xid
247+ if ext.Info(xturbo.ExtXFixes).Present {
248+ if x.selWin, err = conn.NewXID(); err != nil {
249+ return fmt.Errorf("xwm: allocate window id: %w", err)
250+ }
251+ }
252+
253+ b := conn.Buf()
254+ x.expect(composite.QueryVersion(b, 0, 4), kindVersion, versionComposite, 0)
255+ if ext.Info(xturbo.ExtXFixes).Present {
256+ x.expect(xfixes.QueryVersion(b, 6, 0), kindVersion, versionXFixes, 0)
257+ }
258+ if ext.Info(xturbo.ExtRes).Present {
259+ x.expect(res.QueryVersion(b, 1, 2), kindVersion, versionRes, 0)
260+ }
261+
262+ root := xproto.Window(x.root)
263+ var rootMask xproto.ChangeWindowAttributesValues
264+ rootMask.SetEventMask(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
265+ x.expect(xproto.ChangeWindowAttributes(b, root, &rootMask), kindRootMask, 0, 0)
266+ // Only one client may select SubstructureRedirect on the root: wait for
267+ // the answer before touching anything another window manager owns.
268+ if err := b.Err(); err != nil {
269+ return fmt.Errorf("xwm: encode root mask: %w", err)
270+ }
271+ if err := conn.Sync(&x.h); err != nil {
272+ return fmt.Errorf("xwm: root mask: %w", err)
273+ }
274+ if x.bootErr != nil {
275+ return x.bootErr
276+ }
277+ composite.RedirectSubwindows(b, root, composite.RedirectManual)
278+
279+ var wv xproto.CreateWindowValues
280+ wv.SetEventMask(0)
281+ xproto.CreateWindow(b, 0, xproto.Window(x.wmWin), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOutput, 0, &wv)
282+ name := []byte("neferwl")
283+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMName]),
284+ xproto.Atom(x.atoms[atomUTF8String]), 8, uint32(len(name)), name)
285+ x.setProp32(x.root, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
286+ x.setProp32(x.wmWin, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
287+ // CurrentTime and no MANAGER announcement: this Xwayland is private to
288+ // neferwl and nobody waits on these selections.
289+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomWMS0]), 0)
290+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMCMS0]), 0)
291+
292+ x.bootSelection(root)
293+
294+ supported := [...]int{
295+ atomNetWMState, atomNetActiveWindow, atomNetWMStateFocused,
296+ atomNetWMStateModal, atomNetWMStateFullscreen, atomNetClientList,
297+ atomNetClientListStacking, atomNetSupportingWMCheck, atomNetWMName, atomNetWMPID,
298+ atomNetWMWindowType,
299+ }
300+ var list [len(supported)]uint32
301+ for i, a := range supported {
302+ list[i] = x.atoms[a]
303+ }
304+ x.setProp32(x.root, atomNetSupported, xproto.AtomAtom, list[:])
305+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, []uint32{0})
306+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, nil)
307+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, nil)
308+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
309+ if err := b.Err(); err != nil {
310+ return fmt.Errorf("xwm: encode setup requests: %w", err)
311+ }
312+ if err := conn.Sync(&x.h); err != nil {
313+ return fmt.Errorf("xwm: root setup: %w", err)
314+ }
315+ if x.bootErr != nil {
316+ return x.bootErr
317+ }
318+
319+ if err := x.bootSettings(); err != nil {
320+ return err
321+ }
322+
323+ // Windows that exist already (Xwayland restarted under a live client set).
324+ x.expect(xproto.QueryTree(b, root), kindTree, 0, 0)
325+ if err := conn.Sync(&x.h); err != nil {
326+ return fmt.Errorf("xwm: query tree: %w", err)
327+ }
328+ return x.bootErr
329+}
330+
331+// Attach registers the X socket with the display and hands the windows found
332+// at boot to h. Display goroutine only.
333+func (x *XWM) Attach(d *server.Display, h host) error {
334+ src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
335+ if err != nil {
336+ return fmt.Errorf("xwm: watch X socket: %w", err)
337+ }
338+ x.attach(src, h, d.Post)
339+ x.lp.(*displayLoop).d = d
340+ return nil
341+}
342+
343+// loop is what the XWM needs from the display beyond the X socket source:
344+// fd sources for transfer pipes and one-shot timers.
345+type loop interface {
346+ AddFD(fd int, onReadable, onWritable func()) (fdSource, error)
347+ After(d time.Duration, fn func()) (cancel func())
348+}
349+
350+// displayLoop is the production loop: the display's fd sources, the host's timers.
351+type displayLoop struct {
352+ d *server.Display
353+ h host
354+}
355+
356+func (l *displayLoop) AddFD(fd int, r, w func()) (fdSource, error) {
357+ if l.d == nil {
358+ return nil, errors.New("xwm: no display")
359+ }
360+ s, err := l.d.AddFDRW(fd, r, w)
361+ if err != nil {
362+ return nil, err
363+ }
364+ return s, nil
365+}
366+
367+func (l *displayLoop) After(d time.Duration, fn func()) func() { return l.h.After(d, fn) }
368+
369+// attach is Attach over its seams: the event source and the display's Post.
370+func (x *XWM) attach(src fdSource, h host, post func(func())) {
371+ x.src, x.host, x.post = src, h, post
372+ x.lp = &displayLoop{h: h}
373+ x.flushFn = func() {
374+ x.posted = false
375+ x.flush()
376+ }
377+ for i := range x.tab.recs {
378+ r := &x.tab.recs[i]
379+ if r.kind == kindUnmanaged && r.mapped && !r.ready { // mapped before Attach
380+ x.requestOverrideType(Slot(i + 1))
381+ }
382+ if r.kind != kindManaged || r.id != 0 {
383+ continue
384+ }
385+ s := Slot(i + 1)
386+ x.adopt(s)
387+ if r.mapping { // MapRequest or viewable before Attach
388+ r.mapping = false
389+ x.beginMap(s)
390+ }
391+ }
392+ x.flush()
393+}
394+
395+// Close detaches from the display, drops every window from the host and closes
396+// the X connection. Idempotent.
397+func (x *XWM) Close() {
398+ if x.closed {
399+ return
400+ }
401+ x.closed = true
402+ x.selClose()
403+ s := x.stats
404+ x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
405+ Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).
406+ Uint64("legacy_id", s.LegacyID).Uint64("unmanaged_seen", s.UnmanagedSeen).Msg("X11 window management closed")
407+ if x.src != nil {
408+ x.src.Remove()
409+ x.src = nil
410+ }
411+ if x.host != nil {
412+ for i := range x.tab.recs {
413+ r := &x.tab.recs[i]
414+ switch {
415+ case r.kind == kindManaged && r.id != 0:
416+ r.id = 0
417+ x.host.Dissociate(Slot(i+1), true)
418+ case r.kind == kindUnmanaged && r.ovPaired:
419+ r.ovPaired = false
420+ x.host.OverrideGone(Slot(i + 1))
421+ }
422+ }
423+ }
424+ x.unpairedN = 0
425+ _ = x.conn.Close()
426+}
427+
428+// CloseConn closes the connection of an XWM that was never attached.
429+func (x *XWM) CloseConn() {
430+ if !x.closed {
431+ x.selClose()
432+ x.closed = true
433+ _ = x.conn.Close()
434+ }
435+}
436+
437+// fail ends window management after an unrecoverable connection error. Xwayland
438+// keeps running; its windows no longer map.
439+func (x *XWM) fail(err error) {
440+ if x.closed {
441+ return
442+ }
443+ if errors.Is(err, io.EOF) || errors.Is(err, xturbo.ErrClosed) {
444+ x.log.Info().Err(err).Msg("X11 window management stopped: Xwayland closed the connection")
445+ } else {
446+ x.log.Warn().Err(err).Msg("X11 window management stopped")
447+ }
448+ x.Close()
449+}
450+
451+// Stats returns the counters; Close logs them.
452+func (x *XWM) Stats() Stats { return x.stats }
453+
454+func (x *XWM) onReadable() {
455+ if x.closed {
456+ return
457+ }
458+ if err := x.conn.Dispatch(&x.h); err != nil {
459+ x.fail(err)
460+ return
461+ }
462+ x.flush()
463+}
464+
465+func (x *XWM) onWritable() { x.flush() }
466+
467+// wake schedules one flush at the end of the current display iteration.
468+func (x *XWM) wake() {
469+ if x.posted || x.closed || x.post == nil {
470+ return
471+ }
472+ x.posted = true
473+ x.post(x.flushFn)
474+}
475+
476+// flush sends what the handlers queued: pending configures, an XID prefetch,
477+// then the socket, arming POLLOUT only while it is full.
478+func (x *XWM) flush() {
479+ if x.closed {
480+ return
481+ }
482+ x.flushConfigures()
483+ if x.conn.XIDsLow() {
484+ _ = x.conn.PrefetchXIDs()
485+ }
486+ err := x.conn.Flush()
487+ switch {
488+ case err == nil:
489+ if x.writable {
490+ x.setWritable(false)
491+ }
492+ case errors.Is(err, xturbo.ErrWouldBlock):
493+ if !x.writable {
494+ x.setWritable(true)
495+ }
496+ default:
497+ x.fail(err)
498+ }
499+}
500+
501+func (x *XWM) setWritable(on bool) {
502+ x.writable = on
503+ if x.src == nil {
504+ return
505+ }
506+ if err := x.src.SetWritable(on); err != nil {
507+ x.fail(err)
508+ }
509+}
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
@@ -0,0 +1,387 @@
1+package xwm
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "bnema.dev/xturbo/xproto"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
14+ "github.com/rs/zerolog"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func testLog() zerowrap.Logger { return zerowrap.Logger{Logger: zerolog.Nop()} }
20+
21+func bootPeer(t *testing.T, mutate func(p *xpeer)) (*XWM, *xpeer) {
22+ t.Helper()
23+ fd, p := startXPeer(t, fullPeerExts())
24+ if mutate != nil {
25+ p.set(mutate)
26+ }
27+ x, err := Boot(context.Background(), fd, testLog())
28+ require.NoError(t, err)
29+ t.Cleanup(x.CloseConn)
30+ return x, p
31+}
32+
33+func atomsOf(p xpeerProp) []uint32 {
34+ out := make([]uint32, len(p.data)/4)
35+ for i := range out {
36+ out[i] = binary.LittleEndian.Uint32(p.data[4*i:])
37+ }
38+ return out
39+}
40+
41+func TestBootRequests(t *testing.T) {
42+ x, p := bootPeer(t, nil)
43+ reqs := p.requests()
44+
45+ first := -1
46+ for i, r := range reqs {
47+ if r.major == opInternAtom {
48+ first = i
49+ break
50+ }
51+ }
52+ require.NotEqual(t, -1, first)
53+ for i := 0; i < atomCount; i++ {
54+ r := reqs[first+i]
55+ require.EqualValues(t, opInternAtom, r.major, i)
56+ n := int(binary.LittleEndian.Uint16(r.raw[4:]))
57+ require.Equal(t, atomNames[i], string(r.raw[8:8+n]), i)
58+ require.Equal(t, p.atom(atomNames[i]), x.atoms[i])
59+ }
60+
61+ // Root mask, then composite redirect, then the WM window and ownership.
62+ var rootMask, redirect, create, s0, cm, focus int
63+ rootMask, redirect, create, s0, cm, focus = -1, -1, -1, -1, -1, -1
64+ wm := x.wmWin
65+ for i := first + atomCount; i < len(reqs); i++ {
66+ r := reqs[i]
67+ switch {
68+ case r.major == opChangeWindowAttributes && r.u32(4) == peerRoot:
69+ rootMask = i
70+ require.EqualValues(t, 1<<11, r.u32(8))
71+ want := uint32(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
72+ require.Equal(t, want, r.u32(12))
73+ case r.major == 142 && r.minor == 2:
74+ redirect = i
75+ require.EqualValues(t, peerRoot, r.u32(4))
76+ require.EqualValues(t, 1, r.raw[8], "manual")
77+ case r.major == opCreateWindow && r.u32(4) == wm:
78+ create = i
79+ require.Equal(t, wm, r.u32(4))
80+ case r.major == opSetSelectionOwner && r.u32(8) == p.atom("WM_S0"):
81+ s0 = i
82+ require.Equal(t, wm, r.u32(4))
83+ case r.major == opSetSelectionOwner && r.u32(8) == p.atom("_NET_WM_CM_S0"):
84+ cm = i
85+ case r.major == opSetInputFocus:
86+ focus = i
87+ require.EqualValues(t, 0, r.u32(4), "focus None")
88+ }
89+ }
90+ for name, i := range map[string]int{"rootMask": rootMask, "redirect": redirect, "create": create, "WM_S0": s0, "CM_S0": cm, "focus": focus} {
91+ require.NotEqual(t, -1, i, name)
92+ }
93+ require.Less(t, rootMask, redirect)
94+ require.Less(t, redirect, create)
95+ require.Less(t, create, s0)
96+
97+ sup, ok := p.prop(peerRoot, "_NET_SUPPORTED")
98+ require.True(t, ok)
99+ var names []string
100+ byAtom := map[uint32]string{}
101+ for n, a := range p.atoms {
102+ byAtom[a] = n
103+ }
104+ for _, a := range atomsOf(sup) {
105+ names = append(names, byAtom[a])
106+ }
107+ require.Equal(t, []string{
108+ "_NET_WM_STATE", "_NET_ACTIVE_WINDOW", "_NET_WM_STATE_FOCUSED",
109+ "_NET_WM_STATE_MODAL", "_NET_WM_STATE_FULLSCREEN", "_NET_CLIENT_LIST",
110+ "_NET_CLIENT_LIST_STACKING", "_NET_SUPPORTING_WM_CHECK", "_NET_WM_NAME", "_NET_WM_PID",
111+ "_NET_WM_WINDOW_TYPE",
112+ }, names)
113+ require.NotContains(t, names, "_NET_WM_MOVERESIZE")
114+ require.NotContains(t, names, "_NET_WM_STATE_MAXIMIZED_VERT")
115+
116+ check, ok := p.prop(peerRoot, "_NET_SUPPORTING_WM_CHECK")
117+ require.True(t, ok)
118+ require.Equal(t, []uint32{wm}, atomsOf(check))
119+ check, ok = p.prop(wm, "_NET_SUPPORTING_WM_CHECK")
120+ require.True(t, ok)
121+ require.Equal(t, []uint32{wm}, atomsOf(check))
122+ name, ok := p.prop(wm, "_NET_WM_NAME")
123+ require.True(t, ok)
124+ require.Equal(t, "neferwl", string(name.data))
125+ active, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
126+ require.True(t, ok)
127+ require.Equal(t, []uint32{0}, atomsOf(active))
128+ for _, l := range []string{"_NET_CLIENT_LIST", "_NET_CLIENT_LIST_STACKING"} {
129+ v, ok := p.prop(peerRoot, l)
130+ require.True(t, ok, l)
131+ require.Empty(t, v.data, l)
132+ }
133+}
134+
135+func TestBootWMExists(t *testing.T) {
136+ fd, p := startXPeer(t, fullPeerExts())
137+ p.set(func(p *xpeer) { p.denyRoot = true })
138+ x, err := Boot(context.Background(), fd, testLog())
139+ require.ErrorIs(t, err, ErrWMExists)
140+ require.Nil(t, x)
141+ // Nothing the running window manager owns was touched.
142+ for _, r := range p.requests() {
143+ require.NotEqual(t, uint8(opSetSelectionOwner), r.major)
144+ if r.major == opChangeProperty {
145+ require.NotEqual(t, uint32(peerRoot), r.u32(4))
146+ }
147+ }
148+}
149+
150+func TestBootNoComposite(t *testing.T) {
151+ exts := fullPeerExts()
152+ delete(exts, "Composite")
153+ fd, _ := startXPeer(t, exts)
154+ _, err := Boot(context.Background(), fd, testLog())
155+ require.ErrorIs(t, err, ErrNoComposite)
156+}
157+
158+func TestBootOptionalExtensions(t *testing.T) {
159+ exts := fullPeerExts()
160+ delete(exts, "XFIXES")
161+ delete(exts, "X-Resource")
162+ fd, _ := startXPeer(t, exts)
163+ x, err := Boot(context.Background(), fd, testLog())
164+ require.NoError(t, err)
165+ x.CloseConn()
166+}
167+
168+func TestBootConnectFailure(t *testing.T) {
169+ fd, p := startXPeer(t, fullPeerExts())
170+ p.hangup()
171+ _, err := Boot(context.Background(), fd, testLog())
172+ require.Error(t, err)
173+}
174+
175+func TestBootExistingWindows(t *testing.T) {
176+ x, p := bootPeer(t, func(p *xpeer) {
177+ p.children = []uint32{0x400001, 0x400002}
178+ p.attrs[0x400002] = [2]byte{1, 2} // override-redirect, viewable
179+ })
180+ _ = p
181+ require.NotZero(t, x.tab.lookup(0x400001))
182+ require.NotZero(t, x.tab.lookup(0x400002))
183+ require.Equal(t, kindManaged, x.tab.rec(x.tab.lookup(0x400001)).kind)
184+ require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x400002)).kind)
185+}
186+
187+// readUntil dispatches the socket until cond holds.
188+func readUntil(t *testing.T, x *XWM, cond func() bool) {
189+ t.Helper()
190+ deadline := time.Now().Add(5 * time.Second)
191+ for !cond() {
192+ if time.Now().After(deadline) {
193+ t.Fatal("condition never held")
194+ }
195+ x.onReadable()
196+ time.Sleep(time.Millisecond)
197+ }
198+}
199+
200+func newPost() (post func(func()), runs *[]func()) {
201+ var q []func()
202+ return func(f func()) { q = append(q, f) }, &q
203+}
204+
205+func TestAttachAnnouncesWindowsFoundAtBoot(t *testing.T) {
206+ x, _ := bootPeer(t, func(p *xpeer) {
207+ p.children = []uint32{0x400001, 0x400002}
208+ p.attrs[0x400002] = [2]byte{1, 2}
209+ })
210+ h := newMockhost(t)
211+ src := newMockfdSource(t)
212+ h.EXPECT().Created(x.tab.lookup(0x400001), uint32(0x400001)).Return(ports.WindowID(7)).Once()
213+ post, _ := newPost()
214+ x.attach(src, h, post)
215+ require.Equal(t, ports.WindowID(7), x.tab.rec(x.tab.lookup(0x400001)).id)
216+}
217+
218+func attached(t *testing.T) (*XWM, *xpeer, *mockhost, *mockfdSource, *[]func()) {
219+ t.Helper()
220+ x, p := bootPeer(t, nil)
221+ h := newMockhost(t)
222+ src := newMockfdSource(t)
223+ post, runs := newPost()
224+ x.attach(src, h, post)
225+ return x, p, h, src, runs
226+}
227+
228+func TestCreateAndDestroyManaged(t *testing.T) {
229+ x, p, h, src, _ := attached(t)
230+ const win = 0x500001
231+ h.EXPECT().Created(mock.Anything, uint32(win)).Return(ports.WindowID(3)).Once()
232+ p.createNotify(win, false)
233+ readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 })
234+ s := x.tab.lookup(win)
235+ require.Equal(t, kindManaged, x.tab.rec(s).kind)
236+ require.Equal(t, ports.WindowID(3), x.tab.rec(s).id)
237+
238+ // The window is watched for property and focus changes.
239+ req, _ := p.waitReq(0, func(r xpeerReq) bool { return r.major == opChangeWindowAttributes && r.u32(4) == win })
240+ require.EqualValues(t, xproto.EventMaskPropertyChange|xproto.EventMaskFocusChange, req.u32(12))
241+
242+ h.EXPECT().Dissociate(s, true).Once()
243+ p.destroyNotify(win)
244+ readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 })
245+ require.Zero(t, x.tab.n)
246+ _ = src
247+}
248+
249+// The X server reports the XWM's own check window to it like any other
250+// child of the root; it is never adopted.
251+func TestOwnWindowNeverAdopted(t *testing.T) {
252+ x, p, _, _, _ := attached(t) // the mock fails the test on any host call
253+ p.createNotify(x.wmWin, false)
254+ const marker = 0x500003
255+ p.createNotify(marker, true)
256+ readUntil(t, x, func() bool { return x.tab.lookup(marker) != 0 })
257+ require.Zero(t, x.tab.lookup(x.wmWin))
258+}
259+
260+func TestOverrideRedirectNeverReachesHost(t *testing.T) {
261+ x, p, _, _, _ := attached(t) // the mock fails the test on any host call
262+ p.createNotify(0x500002, true)
263+ readUntil(t, x, func() bool { return x.tab.lookup(0x500002) != 0 })
264+ require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x500002)).kind)
265+ require.EqualValues(t, 1, x.Stats().UnmanagedSeen)
266+ p.destroyNotify(0x500002)
267+ readUntil(t, x, func() bool { return x.tab.lookup(0x500002) == 0 })
268+}
269+
270+func TestCloseDissociatesEverything(t *testing.T) {
271+ x, p, h, src, _ := attached(t)
272+ for _, win := range []uint32{0x500001, 0x500002} {
273+ h.EXPECT().Created(mock.Anything, win).Return(ports.WindowID(win)).Once()
274+ p.createNotify(win, false)
275+ }
276+ p.createNotify(0x500003, true)
277+ readUntil(t, x, func() bool { return x.tab.lookup(0x500003) != 0 })
278+ h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once()
279+ h.EXPECT().Dissociate(x.tab.lookup(0x500002), true).Once()
280+ src.EXPECT().Remove().Once()
281+ x.Close()
282+ x.Close() // idempotent
283+ require.True(t, x.closed)
284+ require.Equal(t, -1, x.conn.Fd())
285+}
286+
287+func TestEOFClosesXWM(t *testing.T) {
288+ x, p, h, src, _ := attached(t)
289+ h.EXPECT().Created(mock.Anything, uint32(0x500001)).Return(ports.WindowID(1)).Once()
290+ p.createNotify(0x500001, false)
291+ readUntil(t, x, func() bool { return x.tab.lookup(0x500001) != 0 })
292+ h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once()
293+ src.EXPECT().Remove().Once()
294+ p.hangup()
295+ readUntil(t, x, func() bool { return x.closed })
296+}
297+
298+func TestWakeCoalescesFlush(t *testing.T) {
299+ x, _, _, _, runs := attached(t)
300+ x.wake()
301+ x.wake()
302+ x.wake()
303+ require.Len(t, *runs, 1)
304+ (*runs)[0]()
305+ x.wake()
306+ require.Len(t, *runs, 2)
307+}
308+
309+func TestWriteBackpressure(t *testing.T) {
310+ x, p, _, src, _ := attached(t)
311+ p.pause()
312+ b := x.conn.Buf()
313+ big := make([]byte, 64<<10)
314+ for i := 0; i < 64; i++ { // 4 MiB: more than any socket buffer
315+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.root), xproto.AtomWmName, xproto.AtomString, 8, uint32(len(big)), big)
316+ }
317+ src.EXPECT().SetWritable(true).Return(nil).Once()
318+ x.flush()
319+ require.True(t, x.writable)
320+ x.flush() // still blocked: SetWritable is not called again
321+ x.onWritable()
322+ require.True(t, x.writable)
323+
324+ drained := false
325+ src.EXPECT().SetWritable(false).RunAndReturn(func(bool) error { drained = true; return nil }).Once()
326+ p.resume()
327+ deadline := time.Now().Add(10 * time.Second)
328+ for !drained {
329+ require.True(t, time.Now().Before(deadline), "never drained")
330+ x.onWritable()
331+ time.Sleep(time.Millisecond)
332+ }
333+ require.False(t, x.writable)
334+}
335+
336+func TestFlushWithNothingQueuedSendsNothing(t *testing.T) {
337+ x, p, _, _, _ := attached(t)
338+ n := len(p.requests())
339+ x.flush()
340+ time.Sleep(20 * time.Millisecond)
341+ require.Len(t, p.requests(), n, "nothing queued, nothing sent")
342+}
343+
344+// bootOnlyFiles run only from boot() (XSETTINGS manager, root cursor).
345+var bootOnlyFiles = map[string]bool{"selection_xsettings.go": true, "cursor.go": true}
346+
347+// The XWM never blocks after Boot: NewXID, Sync and InternAtoms are startup
348+// only, so they may appear in production code only inside boot().
349+func TestNoBlockingCallsOutsideBoot(t *testing.T) {
350+ files, err := os.ReadDir(".")
351+ require.NoError(t, err)
352+ for _, f := range files {
353+ name := f.Name()
354+ if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
355+ continue
356+ }
357+ src, err := os.ReadFile(name)
358+ require.NoError(t, err)
359+ text := string(src)
360+ if bootOnlyFiles[name] {
361+ continue
362+ }
363+ lo, hi := 0, 0
364+ if name == "xwm.go" {
365+ lo = strings.Index(text, "func (x *XWM) boot() error")
366+ hi = strings.Index(text, "// Attach registers")
367+ require.Greater(t, hi, lo)
368+ }
369+ for _, call := range []string{".NewXID(", ".Sync(", ".InternAtoms("} {
370+ for _, at := range allIndex(text, call) {
371+ require.True(t, name == "xwm.go" && at > lo && at < hi, "%s: %s outside boot", name, call)
372+ }
373+ }
374+ }
375+}
376+
377+func allIndex(s, sub string) []int {
378+ var out []int
379+ for off := 0; ; {
380+ i := strings.Index(s[off:], sub)
381+ if i < 0 {
382+ return out
383+ }
384+ out = append(out, off+i)
385+ off += i + len(sub)
386+ }
387+}
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
@@ -0,0 +1,143 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockxwmSelection creates a new instance of mockxwmSelection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockxwmSelection(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockxwmSelection {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockxwmSelection{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockxwmSelection is an autogenerated mock type for the xwmSelection type
35+type mockxwmSelection struct {
36+ mock.Mock
37+}
38+
39+type mockxwmSelection_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockxwmSelection) EXPECT() *mockxwmSelection_Expecter {
44+ return &mockxwmSelection_Expecter{mock: &_m.Mock}
45+}
46+
47+// SelectionReceive provides a mock function for the type mockxwmSelection
48+func (_mock *mockxwmSelection) SelectionReceive(kind int, mime string, fd int) {
49+ _mock.Called(kind, mime, fd)
50+ return
51+}
52+
53+// mockxwmSelection_SelectionReceive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionReceive'
54+type mockxwmSelection_SelectionReceive_Call struct {
55+ *mock.Call
56+}
57+
58+// SelectionReceive is a helper method to define mock.On call
59+// - kind int
60+// - mime string
61+// - fd int
62+func (_e *mockxwmSelection_Expecter) SelectionReceive(kind any, mime any, fd any) *mockxwmSelection_SelectionReceive_Call {
63+ return &mockxwmSelection_SelectionReceive_Call{Call: _e.mock.On("SelectionReceive", kind, mime, fd)}
64+}
65+
66+func (_c *mockxwmSelection_SelectionReceive_Call) Run(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 int
69+ if args[0] != nil {
70+ arg0 = args[0].(int)
71+ }
72+ var arg1 string
73+ if args[1] != nil {
74+ arg1 = args[1].(string)
75+ }
76+ var arg2 int
77+ if args[2] != nil {
78+ arg2 = args[2].(int)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ arg2,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *mockxwmSelection_SelectionReceive_Call) Return() *mockxwmSelection_SelectionReceive_Call {
90+ _c.Call.Return()
91+ return _c
92+}
93+
94+func (_c *mockxwmSelection_SelectionReceive_Call) RunAndReturn(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
95+ _c.Run(run)
96+ return _c
97+}
98+
99+// WaylandSelection provides a mock function for the type mockxwmSelection
100+func (_mock *mockxwmSelection) WaylandSelection(kind int, mimes []string) {
101+ _mock.Called(kind, mimes)
102+ return
103+}
104+
105+// mockxwmSelection_WaylandSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaylandSelection'
106+type mockxwmSelection_WaylandSelection_Call struct {
107+ *mock.Call
108+}
109+
110+// WaylandSelection is a helper method to define mock.On call
111+// - kind int
112+// - mimes []string
113+func (_e *mockxwmSelection_Expecter) WaylandSelection(kind any, mimes any) *mockxwmSelection_WaylandSelection_Call {
114+ return &mockxwmSelection_WaylandSelection_Call{Call: _e.mock.On("WaylandSelection", kind, mimes)}
115+}
116+
117+func (_c *mockxwmSelection_WaylandSelection_Call) Run(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 int
120+ if args[0] != nil {
121+ arg0 = args[0].(int)
122+ }
123+ var arg1 []string
124+ if args[1] != nil {
125+ arg1 = args[1].([]string)
126+ }
127+ run(
128+ arg0,
129+ arg1,
130+ )
131+ })
132+ return _c
133+}
134+
135+func (_c *mockxwmSelection_WaylandSelection_Call) Return() *mockxwmSelection_WaylandSelection_Call {
136+ _c.Call.Return()
137+ return _c
138+}
139+
140+func (_c *mockxwmSelection_WaylandSelection_Call) RunAndReturn(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
141+ _c.Run(run)
142+ return _c
143+}
A internal/adapters/xwayland/builtin.go +216 -0
@@ -0,0 +1,216 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+type builtinStarter struct{}
18+
19+func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
20+ // Files own all setup descriptors until Start succeeds and Attach takes
21+ // the Wayland socket. This also unwinds partial setup failures.
22+ var files []*os.File
23+ defer func() {
24+ for _, f := range files {
25+ if f != nil {
26+ f.Close()
27+ }
28+ }
29+ }()
30+ pair := func(name string) (int, error) {
31+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
32+ if err != nil {
33+ return 0, err
34+ }
35+ i := len(files)
36+ files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child"))
37+ return i, nil
38+ }
39+ wl, err := pair("wayland")
40+ if err != nil {
41+ return err
42+ }
43+ wm, err := pair("wm")
44+ if err != nil {
45+ return err
46+ }
47+ var pipe [2]int
48+ if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil {
49+ return err
50+ }
51+ rd := os.NewFile(uintptr(pipe[0]), "displayfd read")
52+ wr := os.NewFile(uintptr(pipe[1]), "displayfd write")
53+ files = append(files, rd, wr)
54+ args := []string{d.Name(), "-rootless"}
55+ for i := range d.sockets {
56+ args = append(args, "-listenfd", strconv.Itoa(3+i))
57+ }
58+ wlChild := 3 + len(d.sockets)
59+ args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2))
60+ cmd := exec.Command(d.opts.Binary, args...)
61+ for _, e := range d.opts.Env {
62+ if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") {
63+ cmd.Env = append(cmd.Env, e)
64+ }
65+ }
66+ cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild))
67+ cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr)
68+ cmd.Stderr = stderr
69+ cmd.WaitDelay = 2 * time.Second
70+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
71+ if err := cmd.Start(); err != nil {
72+ return err
73+ }
74+ files[wl+1].Close()
75+ files[wm+1].Close()
76+ wr.Close()
77+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
78+ // Detach the Wayland descriptor from os.File ownership before handing it
79+ // to the display (including its finalizers).
80+ if d.opts.Attach != nil {
81+ // Dup transfers an independent descriptor; closing the File wrapper below
82+ // cannot race with the display's ownership of the transferred descriptor.
83+ var wlFD int
84+ wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ d.opts.Attach(wlFD)
87+ }
88+ }
89+ files[wl].Close()
90+ done := make(chan error, 1)
91+ procGone := make(chan struct{})
92+ go func() { done <- cmd.Wait(); close(procGone) }()
93+ if err != nil {
94+ stopGroup(cmd, done)
95+ return err
96+ }
97+ exited, readyErr := waitReady(ctx, pipe[0], done)
98+ if readyErr != nil {
99+ err := readyErr
100+ if errors.Is(err, errReadinessTimeout) {
101+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
102+ }
103+ // Join Wait unless readiness already observed process exit.
104+ if !exited {
105+ stopGroup(cmd, done)
106+ }
107+ return err
108+ }
109+ rd.Close()
110+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
111+ if d.opts.AttachWM != nil {
112+ // The WM socket is bootstrapped only now: Xwayland serves it after its
113+ // Wayland round trips, which the display goroutine must be free to answer.
114+ wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0)
115+ if err != nil {
116+ stopGroup(cmd, done)
117+ return err
118+ }
119+ // The context ends with the process or the caller's context, so a
120+ // bootstrap against a dead or silent Xwayland aborts at once.
121+ wmCtx, cancel := context.WithCancel(ctx)
122+ watched := make(chan struct{})
123+ go func() {
124+ defer close(watched)
125+ select {
126+ case <-procGone:
127+ cancel()
128+ case <-wmCtx.Done():
129+ }
130+ }()
131+ stop := d.opts.AttachWM(wmCtx, wmFD)
132+ cancel()
133+ <-watched
134+ if stop != nil {
135+ // Runs before start returns so the restart loop never overlaps two XWMs.
136+ defer stop()
137+ }
138+ }
139+ select {
140+ case err := <-done:
141+ return err
142+ case <-ctx.Done():
143+ stopGroup(cmd, done)
144+ return ctx.Err()
145+ }
146+}
147+
148+var errReadinessTimeout = errors.New("xwayland readiness timed out")
149+
150+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
151+// The result reports whether cmd.Wait was already joined.
152+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
153+ deadline := time.Now().Add(10 * time.Second)
154+ var buf [32]byte
155+ n := 0
156+ for {
157+ select {
158+ case err := <-done:
159+ if err == nil {
160+ err = errors.New("xwayland exited before readiness")
161+ }
162+ return true, err
163+ default:
164+ }
165+ if ctx.Err() != nil {
166+ return false, ctx.Err()
167+ }
168+ if time.Now().After(deadline) {
169+ return false, errReadinessTimeout
170+ }
171+ // A closed pipe also covers process exit, including exec's stderr delay.
172+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
173+ _, err := unix.Poll(p, 50)
174+ if errors.Is(err, unix.EINTR) {
175+ continue
176+ }
177+ if err != nil {
178+ return false, err
179+ }
180+ if p[0].Revents == 0 {
181+ continue
182+ }
183+ if p[0].Revents&unix.POLLIN != 0 {
184+ count, err := unix.Read(fd, buf[n:])
185+ if errors.Is(err, unix.EINTR) {
186+ continue
187+ }
188+ if err != nil {
189+ return false, err
190+ }
191+ n += count
192+ if n > 0 && buf[n-1] == '\n' {
193+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
194+ return false, fmt.Errorf("xwayland displayfd: %w", err)
195+ }
196+ return false, nil
197+ }
198+ if n == len(buf) {
199+ return false, errors.New("invalid Xwayland displayfd response")
200+ }
201+ }
202+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
203+ return false, errors.New("xwayland exited before readiness")
204+ }
205+ }
206+}
207+
208+func stopGroup(cmd *exec.Cmd, done <-chan error) {
209+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
210+ select {
211+ case <-done:
212+ case <-time.After(2 * time.Second):
213+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
214+ <-done
215+ }
216+}
A internal/adapters/xwayland/starter_mock_test.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwayland
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockstarter(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockstarter {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockstarter{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockstarter is an autogenerated mock type for the starter type
37+type mockstarter struct {
38+ mock.Mock
39+}
40+
41+type mockstarter_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockstarter) EXPECT() *mockstarter_Expecter {
46+ return &mockstarter_Expecter{mock: &_m.Mock}
47+}
48+
49+// start provides a mock function for the type mockstarter
50+func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
51+ ret := _mock.Called(ctx, d, stderr)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for start")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok {
59+ r0 = returnFunc(ctx, d, stderr)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
67+type mockstarter_start_Call struct {
68+ *mock.Call
69+}
70+
71+// start is a helper method to define mock.On call
72+// - ctx context.Context
73+// - d *Display
74+// - stderr *logWriter
75+func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call {
76+ return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)}
77+}
78+
79+func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 *Display
86+ if args[1] != nil {
87+ arg1 = args[1].(*Display)
88+ }
89+ var arg2 *logWriter
90+ if args[2] != nil {
91+ arg2 = args[2].(*logWriter)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/adapters/xwayland/xwayland.go +46 -18
@@ -1,6 +1,5 @@
1-// Package xwayland runs X11 clients through xwayland-satellite, started on1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2-// demand: neferwl owns the X11 display sockets and starts the satellite2+// sockets and starts the selected backend when the first client connects.
3-// when the first X11 client connects, and again after it exits.
4 package xwayland3 package xwayland
5 4
6 import (5 import (
@@ -17,15 +16,26 @@ import (
17 "syscall"16 "syscall"
18 "time"17 "time"
19 18
19+ "github.com/bnema/neferwl/internal/ports"
20 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
21 "golang.org/x/sys/unix"21 "golang.org/x/sys/unix"
22 )22 )
23 23
24 // Options configures the X11 display.24 // Options configures the X11 display.
25 type Options struct {25 type Options struct {
26- // Binary is the xwayland-satellite executable.26+ Backend ports.XwaylandBackend
27+ // Attach takes ownership of the Wayland descriptor right after the backend
28+ // starts.
29+ Attach func(wlFD int)
30+ // AttachWM takes ownership of the window manager descriptor once the
31+ // backend is ready. It may block to bootstrap the X11 connection; ctx ends
32+ // when the backend exits or the run is cancelled, and the bootstrap must
33+ // then give up. The returned stop (nil for none) is called before the run
34+ // ends, on backend exit or context cancellation.
35+ AttachWM func(ctx context.Context, wmFD int) (stop func())
36+ // Binary is the selected backend executable.
27 Binary string37 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.38+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
29 Env []string39 Env []string
30 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix40 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31 // and /tmp outside tests.41 // and /tmp outside tests.
@@ -35,8 +45,9 @@ type Options struct {
35 Log zerowrap.Logger45 Log zerowrap.Logger
36 }46 }
37 47
38-// Display is an X11 display served by xwayland-satellite.48+// Display is an on-demand X11 display.
39 type Display struct {49 type Display struct {
50+ starter starter
40 opts Options51 opts Options
41 n int52 n int
42 sockets []*os.File // unix socket, then the abstract one53 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) {
81 _ = os.Remove(lock)92 _ = os.Remove(lock)
82 return nil, fmt.Errorf("write X11 lock: %w", err)93 return nil, fmt.Errorf("write X11 lock: %w", err)
83 }94 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}95+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
96+ if opts.Backend == ports.XwaylandBuiltin {
97+ d.starter = builtinStarter{}
98+ }
85 if err := d.listen(); err != nil {99 if err := d.listen(); err != nil {
86 last = err100 last = err
87 d.Close()101 d.Close()
@@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error {
212 return err226 return err
213 }227 }
214 start := time.Now()228 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}229+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
216- err := d.serve(ctx, stderr)230+ err := d.starter.start(ctx, d, stderr)
217 if ctx.Err() != nil {231 if ctx.Err() != nil {
218 return nil232 return nil
219 }233 }
220 if err != nil {234 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")235+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222 } else {236 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")237+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224 }238 }
225 // Clients the satellite never accepted would wake us at once: a239 // Clients the satellite never accepted would wake us at once: a
226 // satellite that cannot start must not respawn in a loop.240 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261 return ctx.Err()275 return ctx.Err()
262 }276 }
263 277
264-// serve runs the satellite on the sockets until it exits or ctx ends.278+// starter runs a backend on the reserved sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {279+type starter interface {
280+ start(ctx context.Context, d *Display, stderr *logWriter) error
281+}
282+
283+type satelliteStarter struct{}
284+
285+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266 args := []string{d.Name()}286 args := []string{d.Name()}
267 for i := range d.sockets {287 for i := range d.sockets {
268 args = append(args, "-listenfd", strconv.Itoa(3+i))288 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +338,11 @@ func (d *Display) dropPending() {
318 // logWriter logs the satellite's stderr line by line and keeps the last338 // logWriter logs the satellite's stderr line by line and keeps the last
319 // lines for the exit log; exec copies it from a pipe on its own goroutine.339 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320 type logWriter struct {340 type logWriter struct {
321- mu sync.Mutex341+ backend string
322- log zerowrap.Logger342+ mu sync.Mutex
323- line []byte343+ log zerowrap.Logger
324- last []string344+ line []byte
345+ last []string
325 }346 }
326 347
327 // tail is the last lines written.348 // tail is the last lines written.
@@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342 }363 }
343 if i > 0 {364 if i > 0 {
344 line := string(w.line[:i])365 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")366+ w.log.Debug().Str("line", line).Msg(w.backend)
346 if w.last = append(w.last, line); len(w.last) > 5 {367 if w.last = append(w.last, line); len(w.last) > 5 {
347 w.last = w.last[1:]368 w.last = w.last[1:]
348 }369 }
@@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354 }375 }
355 return len(p), nil376 return len(p), nil
356 }377 }
378+
379+func (d *Display) backendName() string {
380+ if d.opts.Backend == ports.XwaylandBuiltin {
381+ return "Xwayland"
382+ }
383+ return "xwayland-satellite"
384+}
M internal/adapters/xwayland/xwayland_test.go +207 -0
@@ -11,6 +11,8 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/neferwl/internal/adapters/logging"13 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
15 )17 )
16 18
@@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270 }272 }
271 }273 }
272 }274 }
275+
276+func TestStarterSelection(t *testing.T) {
277+ for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} {
278+ opts := testOptions(t, "true")
279+ opts.Backend = backend
280+ d, err := Open(opts)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ defer d.Close()
285+ switch d.starter.(type) {
286+ case satelliteStarter:
287+ if backend != ports.XwaylandSatellite {
288+ t.Fatal("satellite selected")
289+ }
290+ case builtinStarter:
291+ if backend != ports.XwaylandBuiltin {
292+ t.Fatal("builtin selected")
293+ }
294+ default:
295+ t.Fatalf("unexpected starter %T", d.starter)
296+ }
297+ }
298+}
299+
300+func TestRunCallsStarter(t *testing.T) {
301+ d, err := Open(testOptions(t, "unused"))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ defer d.Close()
306+ ctx, cancel := context.WithCancel(t.Context())
307+ defer cancel()
308+ m := newMockstarter(t)
309+ d.starter = m
310+ m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error {
311+ if got != d || stderr == nil {
312+ t.Error("starter arguments")
313+ }
314+ cancel()
315+ return ctx.Err()
316+ }).Once()
317+ done := make(chan error, 1)
318+ go func() { done <- d.Run(ctx) }()
319+ unix.Close(dial(t, d.socket))
320+ select {
321+ case err := <-done:
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ case <-time.After(3 * time.Second):
326+ t.Fatal("Run did not stop")
327+ }
328+}
329+
330+func TestWaitReady(t *testing.T) {
331+ for _, tt := range []struct {
332+ name, data string
333+ wantErr bool
334+ }{
335+ {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true},
336+ } {
337+ t.Run(tt.name, func(t *testing.T) {
338+ var p [2]int
339+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
340+ t.Fatal(err)
341+ }
342+ defer unix.Close(p[0])
343+ if tt.data != "" {
344+ if _, err := unix.Write(p[1], []byte(tt.data)); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ unix.Close(p[1])
349+ joined, err := waitReady(t.Context(), p[0], make(chan error))
350+ if joined || (err != nil) != tt.wantErr {
351+ t.Fatalf("joined=%v err=%v", joined, err)
352+ }
353+ })
354+ }
355+ t.Run("exit", func(t *testing.T) {
356+ done := make(chan error, 1)
357+ done <- errors.New("exit")
358+ joined, err := waitReady(t.Context(), -1, done)
359+ if !joined || err == nil {
360+ t.Fatal(joined, err)
361+ }
362+ })
363+ t.Run("cancel", func(t *testing.T) {
364+ ctx, cancel := context.WithCancel(t.Context())
365+ cancel()
366+ joined, err := waitReady(ctx, -1, make(chan error))
367+ if joined || !errors.Is(err, context.Canceled) {
368+ t.Fatal(joined, err)
369+ }
370+ })
371+}
372+
373+// readyScript stands in for Xwayland: after a short delay it creates a marker
374+// file and reports readiness on the -displayfd descriptor when ready is true,
375+// then runs tail.
376+func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) {
377+ t.Helper()
378+ bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready")
379+ script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" +
380+ "sleep 0.3\n"
381+ if ready {
382+ script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n"
383+ }
384+ script += tail + "\n"
385+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
386+ t.Fatal(err)
387+ }
388+ return bin, marker
389+}
390+
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) {
392+ t.Helper()
393+ opts := testOptions(t, "")
394+ opts.Backend = ports.XwaylandBuiltin
395+ var marker string
396+ opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail)
397+ exists := func() bool { _, err := os.Stat(marker); return err == nil }
398+ opts.Attach = func(wlFD int) {
399+ if exists() {
400+ t.Error("Attach ran after readiness")
401+ }
402+ events = append(events, "attach")
403+ unix.Close(wlFD)
404+ }
405+ opts.AttachWM = func(ctx context.Context, wmFD int) func() {
406+ if !exists() {
407+ t.Error("AttachWM ran before readiness")
408+ }
409+ events = append(events, "attachWM")
410+ attachWM(ctx, wmFD)
411+ return func() { events = append(events, "stop") }
412+ }
413+ d, err := Open(opts)
414+ if err != nil {
415+ t.Fatal(err)
416+ }
417+ defer d.Close()
418+ _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()})
419+ return append(events, "returned")
420+}
421+
422+// AttachWM runs only after -displayfd readiness; stop runs before start
423+// returns, whether the process exits or the context ends.
424+func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
425+ for name, tc := range map[string]struct {
426+ tail string
427+ cancel bool
428+ }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} {
429+ t.Run(name, func(t *testing.T) {
430+ ctx, cancel := context.WithCancel(t.Context())
431+ defer cancel()
432+ events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) {
433+ if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434+ t.Errorf("wmFD not open: %v", err)
435+ }
436+ unix.Close(wmFD)
437+ if tc.cancel {
438+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
439+ }
440+ })
441+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
442+ t.Fatalf("events %s", got)
443+ }
444+ })
445+ }
446+}
447+
448+// A process that exits before reporting readiness never reaches AttachWM.
449+func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {})
451+ if got := strings.Join(events, ","); got != "attach,returned" {
452+ t.Fatalf("events %s", got)
453+ }
454+}
455+
456+// The AttachWM context ends when the process dies, so a bootstrap against a
457+// dead Xwayland gives up at once instead of waiting out its handshake.
458+func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) {
459+ ended := make(chan error, 1)
460+ start := time.Now()
461+ events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) {
462+ defer unix.Close(wmFD)
463+ select {
464+ case <-ctx.Done():
465+ ended <- ctx.Err()
466+ case <-time.After(5 * time.Second):
467+ ended <- nil
468+ }
469+ })
470+ if err := <-ended; err == nil {
471+ t.Fatal("AttachWM context did not end with the process")
472+ }
473+ if time.Since(start) > 3*time.Second {
474+ t.Fatalf("took %v", time.Since(start))
475+ }
476+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
477+ t.Fatalf("events %s", got)
478+ }
479+}
M internal/app/app.go +13 -6
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "os/exec"
8 "time"9 "time"
9 10
10 "github.com/bnema/neferwl/internal/adapters/logging"11 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188 }189 }
189 }190 }
190 191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns192+// openXwayland reserves an X11 display for the selected backend, or returns
192 // nil when X11 is off or unavailable: the session runs without it.193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
194- bin := opts.Config.Xwayland195+ cfg := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196 return nil198 return nil
197 }199 }
198- if !xwayland.Supported(ctx, bin) {200+ if cfg.Backend == ports.XwaylandBuiltin {
201+ if _, err := exec.LookPath(bin); err != nil {
202+ log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled")
203+ return nil
204+ }
205+ } else if !xwayland.Supported(ctx, bin) {
199 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200 return nil207 return nil
201 }208 }
202- d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})209+ d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203 if err != nil {210 if err != nil {
204 log.Warn().Err(err).Msg("X11 display disabled")211 log.Warn().Err(err).Msg("X11 display disabled")
205 return nil212 return nil
M internal/app/cursor.go +18 -0
@@ -138,3 +138,21 @@ func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.Cu
138 themeCursors.m[key] = out138 themeCursors.m[key] = out
139 return out, nil139 return out, nil
140 }140 }
141+
142+// xwaylandSettings is what the built-in XWM publishes to X11 clients: the
143+// XSETTINGS cursor theme and size and the root cursor image, all from the
144+// XCURSOR_* environment. The DPI follows the layout inside the XWM.
145+func xwaylandSettings(cfg ports.XwaylandConfig) ports.XwaylandSettings {
146+ if cfg.Backend != ports.XwaylandBuiltin {
147+ return ports.XwaylandSettings{}
148+ }
149+ size, theme := cursorSize(), os.Getenv("XCURSOR_THEME")
150+ st := ports.XwaylandSettings{XSettings: cfg.XSettings, CursorTheme: theme, CursorSize: size, RootCursor: true}
151+ img, err := xcursor.Load(xcursor.SearchPath(), theme, []string{"default", "left_ptr"}, size)
152+ if err != nil {
153+ return st // the XWM falls back to the X cursor font
154+ }
155+ img = img.ForSize(size, 64)
156+ st.Cursor = ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}
157+ return st
158+}
M internal/app/session.go +2 -1
@@ -140,6 +140,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
140 RepeatRate: opts.Config.Keyboard.RepeatRate,140 RepeatRate: opts.Config.Keyboard.RepeatRate,
141 RepeatDelay: opts.Config.Keyboard.RepeatDelay,141 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
142 LogDamage: logging.Enabled(ctx, "wayland-damage"),142 LogDamage: logging.Enabled(ctx, "wayland-damage"),
143+ Xwayland: xwaylandSettings(opts.Config.Xwayland),
143 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {144 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
144 return s, err145 return s, err
145 }146 }
@@ -152,7 +153,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152 s.statePath = statePath153 s.statePath = statePath
153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)154 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154 }155 }
155- if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {156+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil {
156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())157 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157 }158 }
158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))159 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
A internal/app/testdata/x11probe/main.go +692 -0
@@ -0,0 +1,692 @@
1+// Command x11probe is a minimal X11 client for the Xwayland end-to-end test.
2+//
3+// It opens one window filled with solid green, prints "mapped WxH" after the
4+// first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5+// window manager sends WM_DELETE_WINDOW.
6+//
7+// With -override it also maps a magenta override-redirect window after the
8+// main one is mapped (prints "override mapped") and unmaps it after
9+// -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10+// it unmaps it instead when the main window loses focus (prints "dismissed").
11+//
12+// With -initial-fullscreen the window maps already fullscreen and fixed-size.
13+// With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN
14+// -fullscreen-delay after the window is mapped (0: right at the map) and
15+// prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to
16+// leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
17+// calls XGrabKeyboard on the window once it is mapped (prints "grab").
18+// -at X,Y creates the window at that root position.
19+//
20+// Four more modes run without a window and exit when done.
21+// -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for
22+// -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above
23+// 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR
24+// aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or
25+// `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, Gdk/WindowScalingFactor, Gdk/UnscaledDPI,
26+// Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none".
27+// -root-cursor prints "cursor WxH" of the XFixes cursor image.
28+package main
29+
30+import (
31+ "crypto/sha256"
32+ "encoding/binary"
33+ "flag"
34+ "fmt"
35+ "os"
36+ "strconv"
37+ "strings"
38+ "time"
39+
40+ "golang.org/x/sys/unix"
41+
42+ "bnema.dev/xturbo"
43+ "bnema.dev/xturbo/shape"
44+ "bnema.dev/xturbo/xfixes"
45+ "bnema.dev/xturbo/xproto"
46+)
47+
48+const (
49+ green = 0x00ff00
50+ magenta = 0xff00ff
51+)
52+
53+func main() {
54+ if err := run(); err != nil {
55+ fmt.Fprintln(os.Stderr, "x11probe:", err)
56+ os.Exit(1)
57+ }
58+}
59+
60+func run() error {
61+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
62+ title := flag.String("title", "probe", "_NET_WM_NAME")
63+ size := flag.String("size", "300x200", "window size WxH")
64+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
65+ override := flag.Bool("override", false, "map a magenta override-redirect window after the main one")
66+ overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates")
67+ overrideSize := flag.String("override-size", "120x80", "override window size WxH")
68+ overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)")
69+ overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")
70+ overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")
71+ dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
72+ fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped")
73+ fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request")
74+ fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
75+ initialFullscreen := flag.Bool("initial-fullscreen", false, "set _NET_WM_STATE_FULLSCREEN and fixed-size WM_NORMAL_HINTS (min=max=size) before mapping")
76+ keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
77+ at := flag.String("at", "0,0", "window position X,Y in root coordinates")
78+ copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window")
79+ copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY")
80+ copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection")
81+ paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window")
82+ xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window")
83+ rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window")
84+ flag.Parse()
85+ if *copyText != "" || *paste != "" || *xsettings || *rootCursor {
86+ return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor)
87+ }
88+ atX, atY, okAt := strings.Cut(*at, ",")
89+ if !okAt {
90+ return fmt.Errorf("bad -at %q", *at)
91+ }
92+ w, h, ok := parseSize(*size)
93+ if !ok {
94+ return fmt.Errorf("bad -size %q", *size)
95+ }
96+
97+ c, err := xturbo.Dial("") // $DISPLAY
98+ if err != nil {
99+ return err
100+ }
101+ defer c.Close()
102+
103+ var atoms [9]uint32
104+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",
105+ "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil {
106+ return err
107+ }
108+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
109+ typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]
110+ netWMState, netWMFullscreen := atoms[7], atoms[8]
111+
112+ b := c.Buf()
113+ xwin, err := c.NewXID()
114+ if err != nil {
115+ return err
116+ }
117+ xgc, err := c.NewXID()
118+ if err != nil {
119+ return err
120+ }
121+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
122+
123+ var v xproto.CreateWindowValues
124+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange)
125+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
126+
127+ wmClass := []byte("x11probe\x00" + *class + "\x00")
128+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
129+ name := []byte(*title)
130+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
131+ var del [4]byte
132+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
133+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
134+
135+ var gv xproto.CreateGCValues
136+ gv.SetForeground(green)
137+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
138+ if err := c.Flush(); err != nil {
139+ return err
140+ }
141+ time.Sleep(*sleep)
142+ if *at != "0,0" {
143+ // Like games, ask for the rect before the map (a ConfigureRequest the
144+ // window manager sees as the output hint).
145+ var cv xproto.ConfigureWindowValues
146+ cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h))
147+ xproto.ConfigureWindow(b, win, &cv)
148+ }
149+ if *initialFullscreen {
150+ // Like a Proton game: fullscreen state and a fixed size, both set
151+ // before the map.
152+ var st [4]byte
153+ binary.LittleEndian.PutUint32(st[:], netWMFullscreen)
154+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.Atom(netWMState), xproto.AtomAtom, 32, 1, st[:])
155+ var nh [72]byte // WM_SIZE_HINTS: flags, x, y, w, h, min, max, ...
156+ binary.LittleEndian.PutUint32(nh[0:], 16|32) // PMinSize|PMaxSize
157+ for _, off := range []int{20, 28} {
158+ binary.LittleEndian.PutUint32(nh[off:], uint32(w))
159+ binary.LittleEndian.PutUint32(nh[off+4:], uint32(h))
160+ }
161+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmNormalHints, xproto.AtomWmSizeHints, 32, 18, nh[:])
162+ }
163+ xproto.MapWindow(b, win)
164+
165+ // The override-redirect window is created once the main window is mapped.
166+ var ovWin xproto.Window
167+ var ovAt, ovSz [2]int
168+ if *override {
169+ var ok1, ok2 bool
170+ x, y, found := strings.Cut(*overrideAt, ",")
171+ ovAt[0], ovAt[1] = atoi(x), atoi(y)
172+ ok1 = found
173+ ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize)
174+ if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") {
175+ return fmt.Errorf("bad -override-at/-override-size/-override-type")
176+ }
177+ }
178+ var ovDeadline time.Time // zero: no timed unmap pending
179+ unmapOverride := func(msg string) {
180+ if ovWin == 0 {
181+ return
182+ }
183+ xproto.UnmapWindow(b, ovWin)
184+ ovWin = 0
185+ ovDeadline = time.Time{}
186+ fmt.Println(msg)
187+ }
188+ mapOverride := func() error {
189+ id, err := c.NewXID()
190+ if err != nil {
191+ return err
192+ }
193+ ovWin = xproto.Window(id)
194+ var ov xproto.CreateWindowValues
195+ ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true)
196+ xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov)
197+ typ := typeMenu
198+ if *overrideType == "tooltip" {
199+ typ = typeTooltip
200+ }
201+ var tb [4]byte
202+ binary.LittleEndian.PutUint32(tb[:], typ)
203+ xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:])
204+ if *overrideShape {
205+ shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil)
206+ }
207+ xproto.MapWindow(b, ovWin)
208+ fmt.Println("override mapped")
209+ if *overrideFor > 0 {
210+ ovDeadline = time.Now().Add(*overrideFor)
211+ }
212+ return nil
213+ }
214+ var ovErr error
215+
216+ // Fullscreen requests go to the root window as a client message, as ICCCM
217+ // and EWMH say for a mapped window.
218+ fsMsg := func(action uint32) {
219+ var ev [32]byte
220+ ev[0] = byte(xproto.KindClientMessage)
221+ ev[1] = 32
222+ binary.LittleEndian.PutUint32(ev[4:], uint32(win))
223+ binary.LittleEndian.PutUint32(ev[8:], netWMState)
224+ binary.LittleEndian.PutUint32(ev[12:], action)
225+ binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen)
226+ xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev)
227+ }
228+ var fsAt, fsOffAt time.Time // zero: nothing pending
229+
230+ mapped, done, isFS := false, false, false
231+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
232+ hd := &xturbo.Handler{
233+ OnEvent: func(ev *xturbo.Event) {
234+ switch ev.Kind {
235+ case xproto.KindExpose:
236+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
237+ case xproto.KindConfigureNotify:
238+ e := xproto.ConfigureNotifyOf(ev)
239+ if e.Window() != win {
240+ break
241+ }
242+ cw, ch = e.Width(), e.Height()
243+ if !mapped {
244+ mapped = true
245+ fmt.Printf("mapped %dx%d\n", cw, ch)
246+ if *override {
247+ ovErr = mapOverride()
248+ }
249+ if *fullscreen {
250+ fsAt = time.Now().Add(*fullscreenDelay)
251+ }
252+ if *keyboardGrab {
253+ xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync)
254+ fmt.Println("grab")
255+ }
256+ }
257+ case xproto.KindPropertyNotify:
258+ if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) {
259+ seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64)
260+ if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
261+ ovErr = err
262+ }
263+ }
264+ case xproto.KindFocusIn:
265+ fmt.Println("focus")
266+ case xproto.KindFocusOut:
267+ if *dismiss && xproto.FocusOutOf(ev).Event() == win {
268+ unmapOverride("dismissed")
269+ }
270+ case xproto.KindClientMessage:
271+ e := xproto.ClientMessageOf(ev)
272+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
273+ done = true
274+ }
275+ }
276+ },
277+ OnReply: func(r *xturbo.Reply) {
278+ rep, ok := xproto.ParseGetPropertyReply(r.Raw)
279+ if !ok {
280+ return
281+ }
282+ on := false
283+ for v := rep.Value(); len(v) >= 4; v = v[4:] {
284+ on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen
285+ }
286+ // Report the state the window manager published, once per change.
287+ switch {
288+ case on && !isFS:
289+ fmt.Printf("fullscreen %dx%d\n", cw, ch)
290+ case !on && isFS:
291+ fmt.Printf("fullscreen off %dx%d\n", cw, ch)
292+ }
293+ isFS = on
294+ },
295+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
296+ }
297+ for !done {
298+ events := int16(unix.POLLIN)
299+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
300+ events |= unix.POLLOUT
301+ } else if err != nil {
302+ return err
303+ }
304+ timeout := -1
305+ for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} {
306+ if !d.IsZero() {
307+ ms := max(int(time.Until(d).Milliseconds())+1, 0)
308+ if timeout < 0 || ms < timeout {
309+ timeout = ms
310+ }
311+ }
312+ }
313+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
314+ if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
315+ return err
316+ }
317+ if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
318+ unmapOverride("override unmapped")
319+ }
320+ if !fsAt.IsZero() && !time.Now().Before(fsAt) {
321+ fsAt = time.Time{}
322+ fsMsg(1)
323+ if *fullscreenOff > 0 {
324+ fsOffAt = time.Now().Add(*fullscreenOff)
325+ }
326+ }
327+ if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) {
328+ fsOffAt = time.Time{}
329+ fsMsg(0)
330+ }
331+ if err := c.Dispatch(hd); err != nil {
332+ return err
333+ }
334+ if ovErr != nil {
335+ return ovErr
336+ }
337+ }
338+ return nil
339+}
340+
341+func parseSize(s string) (w, h int, ok bool) {
342+ ws, hs, found := strings.Cut(s, "x")
343+ if !found {
344+ return 0, 0, false
345+ }
346+ w, err1 := strconv.Atoi(ws)
347+ h, err2 := strconv.Atoi(hs)
348+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
349+}
350+
351+func atoi(s string) int {
352+ n, _ := strconv.Atoi(s)
353+ return n
354+}
355+
356+// tool is the connection of the windowless modes. Events read while waiting
357+// for a reply are queued, so the code reads linearly.
358+type tool struct {
359+ c *xturbo.Conn
360+ b *xturbo.Buf
361+ win xproto.Window
362+ q []xturbo.Event
363+ reply []byte
364+ h xturbo.Handler
365+ atoms map[string]xproto.Atom
366+}
367+
368+const chunk = 65536
369+
370+func newTool() (*tool, error) {
371+ c, err := xturbo.Dial("")
372+ if err != nil {
373+ return nil, err
374+ }
375+ t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}}
376+ t.h = xturbo.Handler{
377+ OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) },
378+ OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) },
379+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
380+ }
381+ names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL",
382+ "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"}
383+ ids := make([]uint32, len(names))
384+ if err := c.InternAtoms(names, false, ids); err != nil {
385+ return nil, err
386+ }
387+ for i, n := range names {
388+ t.atoms[n] = xproto.Atom(ids[i])
389+ }
390+ xid, err := c.NewXID()
391+ if err != nil {
392+ return nil, err
393+ }
394+ t.win = xproto.Window(xid)
395+ var v xproto.CreateWindowValues
396+ v.SetEventMask(xproto.EventMaskPropertyChange)
397+ xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v)
398+ return t, t.c.Sync(&t.h)
399+}
400+
401+// rpc sends the queued requests and returns the reply of seq.
402+func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) {
403+ t.reply = t.reply[:0]
404+ if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
405+ return nil, err
406+ }
407+ if err := t.c.Sync(&t.h); err != nil {
408+ return nil, err
409+ }
410+ return t.reply, nil
411+}
412+
413+// next returns the next event, or false at the deadline.
414+func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) {
415+ for len(t.q) == 0 {
416+ events := int16(unix.POLLIN)
417+ if err := t.c.Flush(); err == xturbo.ErrWouldBlock {
418+ events |= unix.POLLOUT
419+ } else if err != nil {
420+ return xturbo.Event{}, false, err
421+ }
422+ ms := int(time.Until(deadline).Milliseconds()) + 1
423+ if ms <= 0 {
424+ return xturbo.Event{}, false, nil
425+ }
426+ fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}}
427+ if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR {
428+ return xturbo.Event{}, false, err
429+ }
430+ if err := t.c.Dispatch(&t.h); err != nil {
431+ return xturbo.Event{}, false, err
432+ }
433+ }
434+ ev := t.q[0]
435+ t.q = t.q[1:]
436+ return ev, true, nil
437+}
438+
439+func (t *tool) selAtom(name string) (xproto.Atom, error) {
440+ switch name {
441+ case "CLIPBOARD", "PRIMARY":
442+ return t.atoms[name], nil
443+ }
444+ return 0, fmt.Errorf("bad selection %q", name)
445+}
446+
447+func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error {
448+ t, err := newTool()
449+ if err != nil {
450+ return err
451+ }
452+ defer t.c.Close()
453+ switch {
454+ case copyText != "":
455+ return t.copyMode(copyText, copySel, copyFor)
456+ case paste != "":
457+ return t.pasteMode(paste)
458+ case xsettings:
459+ return t.xsettingsMode()
460+ }
461+ return t.cursorMode()
462+}
463+
464+func (t *tool) copyMode(text, selName string, dur time.Duration) error {
465+ data := []byte(text)
466+ if f, ok := strings.CutPrefix(text, "@"); ok {
467+ var err error
468+ if data, err = os.ReadFile(f); err != nil {
469+ return err
470+ }
471+ }
472+ sel, err := t.selAtom(selName)
473+ if err != nil {
474+ return err
475+ }
476+ xproto.SetSelectionOwner(t.b, t.win, sel, 0)
477+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel))
478+ if err != nil {
479+ return err
480+ }
481+ if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win {
482+ return fmt.Errorf("selection %s not owned", selName)
483+ }
484+ fmt.Println("copy owned")
485+ var (
486+ incrReq xproto.Window // requestor of the running INCR transfer
487+ incrProp, incrTgt xproto.Atom
488+ incrOff int
489+ incrLive bool
490+ notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) {
491+ var ev [32]byte
492+ d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop}
493+ d.Put(&ev)
494+ xproto.SendEvent(t.b, false, req, 0, &ev)
495+ }
496+ )
497+ deadline := time.Now().Add(dur)
498+ for {
499+ ev, ok, err := t.next(deadline)
500+ if err != nil || !ok {
501+ return err
502+ }
503+ switch ev.Kind {
504+ case xproto.KindSelectionClear:
505+ fmt.Println("copy lost")
506+ return nil
507+ case xproto.KindSelectionRequest:
508+ e := xproto.SelectionRequestOf(&ev)
509+ req, prop, tgt := e.Requestor(), e.Property(), e.Target()
510+ if prop == 0 {
511+ prop = tgt
512+ }
513+ switch tgt {
514+ case t.atoms["TARGETS"]:
515+ var l [16]byte
516+ for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} {
517+ binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n]))
518+ }
519+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:])
520+ case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]:
521+ if len(data) <= chunk {
522+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data)
523+ break
524+ }
525+ var cv xproto.ChangeWindowAttributesValues
526+ cv.SetEventMask(xproto.EventMaskPropertyChange)
527+ xproto.ChangeWindowAttributes(t.b, req, &cv)
528+ var n [4]byte
529+ binary.LittleEndian.PutUint32(n[:], uint32(len(data)))
530+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:])
531+ incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true
532+ default:
533+ prop = 0
534+ }
535+ notify(req, e.Selection(), tgt, prop, e.Time())
536+ case xproto.KindPropertyNotify:
537+ e := xproto.PropertyNotifyOf(&ev)
538+ if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete {
539+ break
540+ }
541+ end := min(incrOff+chunk, len(data))
542+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end])
543+ if end == incrOff {
544+ incrLive = false
545+ }
546+ incrOff = end
547+ }
548+ }
549+}
550+
551+func (t *tool) pasteMode(selName string) error {
552+ sel, err := t.selAtom(selName)
553+ if err != nil {
554+ return err
555+ }
556+ prop := t.atoms["_PROBE_SEL"]
557+ xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0)
558+ deadline := time.Now().Add(10 * time.Second)
559+ var got []byte
560+ incr := false
561+ get := func() (value []byte, typ xproto.Atom, err error) {
562+ raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22))
563+ if err != nil {
564+ return nil, 0, err
565+ }
566+ r, ok := xproto.ParseGetPropertyReply(raw)
567+ if !ok {
568+ return nil, 0, fmt.Errorf("bad GetProperty reply")
569+ }
570+ return append([]byte(nil), r.Value()...), r.Type(), nil
571+ }
572+ for {
573+ ev, ok, err := t.next(deadline)
574+ if err != nil {
575+ return err
576+ }
577+ if !ok {
578+ return fmt.Errorf("paste timed out")
579+ }
580+ switch ev.Kind {
581+ case xproto.KindSelectionNotify:
582+ if xproto.SelectionNotifyOf(&ev).Property() == 0 {
583+ fmt.Println("paste none")
584+ return nil
585+ }
586+ v, typ, err := get()
587+ if err != nil {
588+ return err
589+ }
590+ if typ != t.atoms["INCR"] {
591+ return printPaste(v)
592+ }
593+ incr = true // reading and deleting the INCR property starts the transfer
594+ case xproto.KindPropertyNotify:
595+ e := xproto.PropertyNotifyOf(&ev)
596+ if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue {
597+ break
598+ }
599+ v, _, err := get()
600+ if err != nil {
601+ return err
602+ }
603+ if len(v) == 0 {
604+ return printPaste(got)
605+ }
606+ got = append(got, v...)
607+ }
608+ deadline = time.Now().Add(10 * time.Second)
609+ }
610+}
611+
612+func printPaste(v []byte) error {
613+ if len(v) <= 4096 {
614+ fmt.Printf("paste %q\n", v)
615+ } else {
616+ fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v))
617+ }
618+ return nil
619+}
620+
621+func (t *tool) xsettingsMode() error {
622+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"]))
623+ if err != nil {
624+ return err
625+ }
626+ r, ok := xproto.ParseGetSelectionOwnerReply(raw)
627+ if !ok || r.Owner() == 0 {
628+ fmt.Println("xsettings none")
629+ return nil
630+ }
631+ raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16))
632+ if err != nil {
633+ return err
634+ }
635+ pr, ok := xproto.ParseGetPropertyReply(raw)
636+ if !ok || len(pr.Value()) < 12 {
637+ fmt.Println("xsettings none")
638+ return nil
639+ }
640+ v := pr.Value()
641+ if v[0] != 0 {
642+ return fmt.Errorf("xsettings: big-endian data")
643+ }
644+ le := binary.LittleEndian
645+ serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:]))
646+ vals := map[string]string{}
647+ p := v[12:]
648+ pad := func(n int) int { return (n + 3) &^ 3 }
649+ for i := 0; i < n; i++ {
650+ if len(p) < 4 {
651+ return fmt.Errorf("xsettings: truncated")
652+ }
653+ typ, nl := p[0], int(le.Uint16(p[2:]))
654+ if len(p) < 4+pad(nl)+4 {
655+ return fmt.Errorf("xsettings: truncated")
656+ }
657+ name := string(p[4 : 4+nl])
658+ p = p[4+pad(nl)+4:] // name, last-change-serial
659+ switch typ {
660+ case 0:
661+ vals[name] = strconv.Itoa(int(int32(le.Uint32(p))))
662+ p = p[4:]
663+ case 1:
664+ sl := int(le.Uint32(p))
665+ vals[name] = string(p[4 : 4+sl])
666+ p = p[4+pad(sl):]
667+ case 2:
668+ p = p[8:]
669+ default:
670+ return fmt.Errorf("xsettings: type %d", typ)
671+ }
672+ }
673+ fmt.Printf("xsettings serial=%d Xft/DPI=%s Gdk/WindowScalingFactor=%s Gdk/UnscaledDPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n",
674+ serial, vals["Xft/DPI"], vals["Gdk/WindowScalingFactor"], vals["Gdk/UnscaledDPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"])
675+ return nil
676+}
677+
678+func (t *tool) cursorMode() error {
679+ if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil {
680+ return err
681+ }
682+ raw, err := t.rpc(xfixes.GetCursorImage(t.b))
683+ if err != nil {
684+ return err
685+ }
686+ r, ok := xfixes.ParseGetCursorImageReply(raw)
687+ if !ok {
688+ return fmt.Errorf("bad GetCursorImage reply")
689+ }
690+ fmt.Printf("cursor %dx%d\n", r.Width(), r.Height())
691+ return nil
692+}
A internal/app/xwayland_test.go +808 -0
@@ -0,0 +1,808 @@
1+//go:build xwayland
2+
3+// End-to-end test of the built-in XWM: a real Xwayland and a real X11 client
4+// (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on
5+// PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with
6+// `make e2e-xwayland`.
7+package app
8+
9+import (
10+ "context"
11+ "crypto/sha256"
12+ "fmt"
13+ "image"
14+ "image/png"
15+ "io"
16+ "os"
17+ "os/exec"
18+ "path/filepath"
19+ "regexp"
20+ "strconv"
21+ "strings"
22+ "sync"
23+ "testing"
24+ "time"
25+
26+ "github.com/bnema/zerowrap"
27+
28+ "github.com/bnema/neferwl/internal/adapters/config"
29+ "github.com/bnema/neferwl/internal/adapters/statefile"
30+ "github.com/bnema/neferwl/internal/ports"
31+)
32+
33+const (
34+ xwaylandTimeout = 10 * time.Second
35+ xwaylandW, xwaylandH = 640, 360
36+)
37+
38+var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
39+
40+// probe is a running x11probe.
41+type probe struct {
42+ cmd *exec.Cmd
43+ out *lockedBuffer
44+ waited chan error
45+}
46+
47+// xwaylandEnv is a running headless compositor with the built-in XWM.
48+type xwaylandEnv struct {
49+ bin, display, statePath, shots string
50+ script *io.PipeWriter
51+}
52+
53+// startXwayland runs the compositor on one headless output and waits for its
54+// X11 display. It skips the test without Xwayland and stops everything when
55+// the test ends.
56+func startXwayland(t *testing.T) *xwaylandEnv {
57+ t.Helper()
58+ return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil)
59+}
60+
61+// startXwaylandWith is startXwayland with the given headless outputs, left to
62+// right, and an optional change to the default config.
63+func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv {
64+ t.Helper()
65+ if _, err := exec.LookPath("Xwayland"); err != nil {
66+ t.Skip("Xwayland not on PATH")
67+ }
68+ // The compositor must not read or write the live session's state.
69+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
70+ if v, ok := os.LookupEnv(k); ok {
71+ os.Unsetenv(k)
72+ t.Cleanup(func() { os.Setenv(k, v) })
73+ }
74+ }
75+ runtime := t.TempDir()
76+ t.Setenv("XDG_RUNTIME_DIR", runtime)
77+ t.Setenv("XDG_STATE_HOME", t.TempDir())
78+ shots := t.TempDir()
79+ bin := buildProbe(t)
80+
81+ script, scriptW := io.Pipe()
82+ logs := &lockedBuffer{}
83+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
84+ cfg := config.Defaults()
85+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}
86+ if mutate != nil {
87+ mutate(&cfg)
88+ }
89+ done := make(chan error, 1)
90+ go func() {
91+ done <- Run(ctx, Options{
92+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
93+ ScreenshotDir: shots, Sizes: sizes,
94+ })
95+ }()
96+ t.Cleanup(func() {
97+ if t.Failed() {
98+ t.Logf("compositor log:\n%s", logs.String())
99+ }
100+ cancel()
101+ _ = scriptW.Close()
102+ select {
103+ case err := <-done:
104+ if err != nil {
105+ t.Errorf("Run: %v", err)
106+ }
107+ case <-time.After(5 * time.Second):
108+ t.Error("Run did not stop")
109+ }
110+ })
111+
112+ var display string
113+ until(t, "X11 display in the log", func() bool {
114+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
115+ display = m[1]
116+ return true
117+ }
118+ return false
119+ })
120+ statePath, err := statefile.Path(runtime, "wayland-1")
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
125+}
126+
127+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
128+// up in the state with its class and pid, is drawn, tiles next to a second
129+// one, receives focus and is closed by the close-window bind.
130+func TestXwaylandProbe(t *testing.T) {
131+ e := startXwayland(t)
132+ bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
133+ first := startProbe(t, bin, display, "-title", "one")
134+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
135+
136+ var st statefile.State
137+ until(t, "one visible X11Probe window", func() bool {
138+ st = readState(statePath)
139+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
140+ })
141+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
142+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
143+ }
144+
145+ // The client draws green and the compositor shows it.
146+ id := st.Windows[0].ID
147+ until(t, "green window in latest.png", func() bool {
148+ r, ok := tileRect(readState(statePath), id)
149+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
150+ })
151+
152+ // Focus round trip: the compositor focused the window, the client saw it.
153+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
154+
155+ // A second probe tiles beside the first.
156+ second := startProbe(t, bin, display, "-title", "two")
157+ until(t, "two tiled windows", func() bool {
158+ st = readState(statePath)
159+ if len(st.Windows) != 2 {
160+ return false
161+ }
162+ for _, w := range st.Windows {
163+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
164+ return false
165+ }
166+ }
167+ return true
168+ })
169+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
170+
171+ // The close-window bind asks the focused (second) probe to close.
172+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
173+ t.Fatal(err)
174+ }
175+ select {
176+ case err := <-second.waited:
177+ if err != nil {
178+ t.Errorf("probe two exited with %v, want 0", err)
179+ }
180+ case <-time.After(2 * time.Second):
181+ t.Fatal("probe two did not exit within 2s of Super+q")
182+ }
183+ until(t, "one window left", func() bool {
184+ st = readState(statePath)
185+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
186+ })
187+ select {
188+ case err := <-first.waited:
189+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
190+ default:
191+ }
192+}
193+
194+// buildProbe builds testdata/x11probe.
195+func buildProbe(t *testing.T) string {
196+ t.Helper()
197+ bin := filepath.Join(t.TempDir(), "x11probe")
198+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
199+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
200+ if out, err := build.CombinedOutput(); err != nil {
201+ t.Fatalf("build x11probe: %v: %s", err, out)
202+ }
203+ return bin
204+}
205+
206+// startProbe starts x11probe on display, with X11 as its only display
207+// server, and stops it when the test ends.
208+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
209+ t.Helper()
210+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
211+ p.cmd = exec.Command(bin, args...)
212+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
213+ p.cmd.Stdout = p.out
214+ stderr := &lockedBuffer{}
215+ p.cmd.Stderr = stderr
216+ if err := p.cmd.Start(); err != nil {
217+ t.Fatal(err)
218+ }
219+ var exited sync.Once
220+ go func() { p.waited <- p.cmd.Wait() }()
221+ t.Cleanup(func() {
222+ exited.Do(func() {
223+ _ = p.cmd.Process.Kill()
224+ select {
225+ case <-p.waited:
226+ case <-time.After(2 * time.Second):
227+ }
228+ })
229+ if s := stderr.String(); s != "" {
230+ t.Logf("x11probe stderr: %s", s)
231+ }
232+ })
233+ return p
234+}
235+
236+func envWithout(keys ...string) []string {
237+ var env []string
238+next:
239+ for _, kv := range os.Environ() {
240+ for _, k := range keys {
241+ if strings.HasPrefix(kv, k+"=") {
242+ continue next
243+ }
244+ }
245+ env = append(env, kv)
246+ }
247+ return env
248+}
249+
250+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
251+func until(t *testing.T, what string, cond func() bool) {
252+ t.Helper()
253+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
254+ if time.Now().After(deadline) {
255+ t.Fatalf("timeout waiting for %s", what)
256+ }
257+ }
258+}
259+
260+// readState returns the current state file, or an empty state while it does
261+// not exist or is being replaced.
262+func readState(path string) statefile.State {
263+ st, err := statefile.Read(path)
264+ if err != nil {
265+ return statefile.State{}
266+ }
267+ return st
268+}
269+
270+// tileRect finds the rectangle of window id in the tiled columns.
271+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
272+ for _, ws := range st.Workspaces {
273+ for _, c := range ws.Columns {
274+ for _, w := range c.Windows {
275+ if w.ID == id {
276+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
277+ }
278+ }
279+ }
280+ }
281+ return image.Rectangle{}, false
282+}
283+
284+// greenShare is the fraction of pixels of r in the PNG at path that are
285+// clearly green; 0 when the image cannot be read yet.
286+func greenShare(path string, r image.Rectangle) float64 {
287+ f, err := os.Open(path)
288+ if err != nil {
289+ return 0
290+ }
291+ defer f.Close()
292+ img, err := png.Decode(f)
293+ if err != nil {
294+ return 0
295+ }
296+ r = r.Intersect(img.Bounds())
297+ if r.Empty() {
298+ return 0
299+ }
300+ n := 0
301+ for y := r.Min.Y; y < r.Max.Y; y++ {
302+ for x := r.Min.X; x < r.Max.X; x++ {
303+ cr, cg, cb, _ := img.At(x, y).RGBA()
304+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
305+ n++
306+ }
307+ }
308+ }
309+ return float64(n) / float64(r.Dx()*r.Dy())
310+}
311+
312+// magentaShare is the fraction of pixels of r in the PNG at path that are
313+// clearly magenta; 0 when the image cannot be read yet.
314+func magentaShare(path string, r image.Rectangle) float64 {
315+ f, err := os.Open(path)
316+ if err != nil {
317+ return 0
318+ }
319+ defer f.Close()
320+ img, err := png.Decode(f)
321+ if err != nil {
322+ return 0
323+ }
324+ r = r.Intersect(img.Bounds())
325+ if r.Empty() {
326+ return 0
327+ }
328+ n := 0
329+ for y := r.Min.Y; y < r.Max.Y; y++ {
330+ for x := r.Min.X; x < r.Max.X; x++ {
331+ cr, cg, cb, _ := img.At(x, y).RGBA()
332+ if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
333+ n++
334+ }
335+ }
336+ }
337+ return float64(n) / float64(r.Dx()*r.Dy())
338+}
339+
340+// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
341+// is drawn above the toplevel at its X position, is not a window in the
342+// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
343+func TestXwaylandOverride(t *testing.T) {
344+ e := startXwayland(t)
345+ // Probe unmaps the menu 3 s after mapping it; the first checks run well
346+ // inside that window.
347+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
348+ "-override-size", "120x80", "-override-for", "3s")
349+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
350+ shot := filepath.Join(e.shots, "latest.png")
351+ box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
352+ until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
353+
354+ var st statefile.State
355+ until(t, "focused X11Probe toplevel", func() bool {
356+ st = readState(e.statePath)
357+ return len(st.Windows) >= 1 && st.Window != nil
358+ })
359+ if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
360+ t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
361+ }
362+ id := st.Windows[0].ID
363+ if st.Window.ID != id {
364+ t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
365+ }
366+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
367+
368+ // The menu is above the toplevel: its box is magenta, not green.
369+ if g := greenShare(shot, box); g > 0.1 {
370+ t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
371+ }
372+ until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
373+ until(t, "menu gone from latest.png", func() bool {
374+ return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
375+ })
376+ if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
377+ t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
378+ }
379+}
380+
381+// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
382+// when core focuses another window, which is how toolkits close their menus.
383+func TestXwaylandOverrideDismiss(t *testing.T) {
384+ e := startXwayland(t)
385+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
386+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
387+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
388+ // A second X11 window takes the focus away from the menu owner.
389+ startProbe(t, e.bin, e.display, "-title", "other")
390+ until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
391+ box := image.Rect(100, 50, 220, 130)
392+ until(t, "menu gone from latest.png", func() bool {
393+ return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
394+ })
395+}
396+
397+// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
398+// and still not a window.
399+func TestXwaylandOverrideShape(t *testing.T) {
400+ e := startXwayland(t)
401+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
402+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
403+ until(t, "magenta tooltip in latest.png", func() bool {
404+ return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
405+ })
406+ until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
407+}
408+
409+// fullOutput is the share of the output screenshot at path that is green.
410+func fullOutput(path string) float64 {
411+ return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH))
412+}
413+
414+// windowOf returns the state window with the given pid.
415+func windowOf(st statefile.State, pid int) (statefile.Window, bool) {
416+ for _, w := range st.Windows {
417+ if w.PID == pid {
418+ return w, true
419+ }
420+ }
421+ return statefile.Window{}, false
422+}
423+
424+// TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the
425+// output, the toggle bind leaves it, and a request made right at the map is
426+// ignored (the grace, current policy).
427+func TestXwaylandFullscreen(t *testing.T) {
428+ // Gaps make a tile visibly smaller than the output.
429+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
430+ shot := filepath.Join(e.shots, "latest.png")
431+ corner := image.Rect(0, 0, 4, 4)
432+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s")
433+ until(t, "tiled with a gap", func() bool {
434+ return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0
435+ })
436+ until(t, "probe fullscreen at output size", func() bool {
437+ return strings.Contains(p.out.String(), "fullscreen 640x360")
438+ })
439+ until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
440+
441+ // The toggle bind (cmd+shift+f) leaves fullscreen.
442+ if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil {
443+ t.Fatal(err)
444+ }
445+ until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
446+ until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 })
447+}
448+
449+// TestXwaylandFullscreenOff: the client leaves fullscreen itself.
450+func TestXwaylandFullscreenOff(t *testing.T) {
451+ e := startXwayland(t)
452+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s")
453+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
454+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
455+ until(t, "tiled", func() bool {
456+ st := readState(e.statePath)
457+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
458+ })
459+}
460+
461+// TestXwaylandInitialFullscreen: a fixed-size window that maps with
462+// _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a
463+// column of its own, focused, beside the tiled window already open. This is
464+// the state it maps with, not a request: the grace does not apply.
465+func TestXwaylandInitialFullscreen(t *testing.T) {
466+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
467+ shot := filepath.Join(e.shots, "latest.png")
468+ first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100")
469+ until(t, "first tiled", func() bool {
470+ return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1
471+ })
472+ p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360")
473+ until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
474+ until(t, "game in its own column, fullscreen", func() bool {
475+ st := readState(e.statePath)
476+ for _, w := range st.Windows {
477+ if w.AppID == "Game" {
478+ return !w.Floating && w.Column == 2 && w.Row == 1
479+ }
480+ }
481+ return false
482+ })
483+ until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
484+ until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") })
485+}
486+
487+// TestXwaylandFullscreenGrace: a request made at once after the map (not the
488+// state it maps with) stays tiled (fullscreenGrace in core).
489+func TestXwaylandFullscreenGrace(t *testing.T) {
490+ e := startXwayland(t)
491+ p := startProbe(t, e.bin, e.display, "-fullscreen")
492+ until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
493+ until(t, "tiled window", func() bool {
494+ st := readState(e.statePath)
495+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
496+ })
497+ // Stay inside the grace, then check it is still not fullscreen.
498+ time.Sleep(500 * time.Millisecond)
499+ st := readState(e.statePath)
500+ if _, ok := tileRect(st, st.Windows[0].ID); !ok {
501+ t.Error("window went fullscreen inside the grace")
502+ }
503+ if strings.Contains(p.out.String(), "fullscreen ") {
504+ t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String())
505+ }
506+}
507+
508+// TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the
509+// keyboard grab the close bind does not fire; after it leaves fullscreen the
510+// binds work again.
511+func TestXwaylandKeyboardGrab(t *testing.T) {
512+ e := startXwayland(t)
513+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab")
514+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
515+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
516+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
517+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
518+ t.Fatal(err)
519+ }
520+ select {
521+ case err := <-p.waited:
522+ t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err)
523+ case <-time.After(2 * time.Second):
524+ }
525+
526+ // The probe leaves fullscreen itself; its grab stays, but the binds work again.
527+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
528+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
529+ t.Fatal(err)
530+ }
531+ select {
532+ case err := <-p.waited:
533+ if err != nil {
534+ t.Errorf("probe exited with %v, want 0", err)
535+ }
536+ case <-time.After(2 * time.Second):
537+ t.Fatal("Super+q did not close the client after it left fullscreen")
538+ }
539+}
540+
541+// TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock
542+// the binds.
543+func TestXwaylandKeyboardGrabWindowed(t *testing.T) {
544+ e := startXwayland(t)
545+ p := startProbe(t, e.bin, e.display, "-keyboard-grab")
546+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
547+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
548+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
549+ t.Fatal(err)
550+ }
551+ select {
552+ case err := <-p.waited:
553+ if err != nil {
554+ t.Errorf("probe exited with %v, want 0", err)
555+ }
556+ case <-time.After(2 * time.Second):
557+ t.Fatal("Super+q did not close a windowed client that holds a keyboard grab")
558+ }
559+}
560+
561+// TestXwaylandOutputHint: with two outputs a window created at the origin of
562+// the second one opens on it, and a fullscreen request fills that output.
563+func TestXwaylandOutputHint(t *testing.T) {
564+ sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}}
565+ e := startXwaylandWith(t, sizes, nil)
566+ p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms")
567+ var w statefile.Window
568+ until(t, "window on HEADLESS-2", func() bool {
569+ var ok bool
570+ w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid)
571+ if ok && w.Output != "HEADLESS-2" {
572+ t.Logf("window output = %s", w.Output)
573+ }
574+ return ok && w.Output == "HEADLESS-2"
575+ })
576+ until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
577+ until(t, "HEADLESS-2 all green", func() bool {
578+ return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99
579+ })
580+ if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 {
581+ t.Errorf("HEADLESS-1 green share = %.2f, want empty", g)
582+ }
583+}
584+
585+// TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window
586+// the way it matches a Wayland app ID (steam_app_<id> from Proton).
587+func TestXwaylandClassRule(t *testing.T) {
588+ yes := true
589+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
590+ c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes})
591+ })
592+ startProbe(t, e.bin, e.display, "-class", "steam_app_413150")
593+ until(t, "floating steam_app window", func() bool {
594+ st := readState(e.statePath)
595+ return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating
596+ })
597+ startProbe(t, e.bin, e.display, "-class", "Other")
598+ until(t, "other class tiled", func() bool {
599+ for _, w := range readState(e.statePath).Windows {
600+ if w.AppID == "Other" {
601+ return !w.Floating
602+ }
603+ }
604+ return false
605+ })
606+}
607+
608+// waylandEnv is the environment of a Wayland client of the headless
609+// compositor: its socket, not the user's session.
610+func waylandEnv() []string {
611+ return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1")
612+}
613+
614+// runProbe runs a one-shot x11probe mode and returns its stdout.
615+func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string {
616+ t.Helper()
617+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
618+ defer cancel()
619+ cmd := exec.CommandContext(ctx, e.bin, args...)
620+ cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display)
621+ out, err := cmd.Output()
622+ if err != nil {
623+ t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out)
624+ }
625+ return strings.TrimSpace(string(out))
626+}
627+
628+// needWlClipboard skips the test without wl-copy and wl-paste.
629+func needWlClipboard(t *testing.T) {
630+ t.Helper()
631+ for _, c := range []string{"wl-copy", "wl-paste"} {
632+ if _, err := exec.LookPath(c); err != nil {
633+ t.Skipf("%s not on PATH (wl-clipboard)", c)
634+ }
635+ }
636+}
637+
638+// wlCopy starts wl-copy --foreground serving data on the headless
639+// compositor's seat and stops it when the test ends.
640+func wlCopy(t *testing.T, data string, args ...string) {
641+ t.Helper()
642+ cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...)
643+ cmd.Env = waylandEnv()
644+ cmd.Stdin = strings.NewReader(data)
645+ stderr := &lockedBuffer{}
646+ cmd.Stderr = stderr
647+ if err := cmd.Start(); err != nil {
648+ t.Fatal(err)
649+ }
650+ waited := make(chan struct{})
651+ go func() { _ = cmd.Wait(); close(waited) }()
652+ t.Cleanup(func() {
653+ _ = cmd.Process.Kill()
654+ <-waited
655+ if s := stderr.String(); s != "" {
656+ t.Logf("wl-copy stderr: %s", s)
657+ }
658+ })
659+}
660+
661+// wlPaste returns what wl-paste prints, and whether it succeeded.
662+func wlPaste(args ...string) (string, bool) {
663+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
664+ defer cancel()
665+ cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...)
666+ cmd.Env = waylandEnv()
667+ out, err := cmd.Output()
668+ return string(out), err == nil
669+}
670+
671+// payload is n bytes of printable text that differ along the way, so a
672+// misplaced chunk is caught.
673+func payload(n int) string {
674+ var b strings.Builder
675+ for i := 0; b.Len() < n; i++ {
676+ fmt.Fprintf(&b, "line %d é\n", i)
677+ }
678+ return b.String()[:n]
679+}
680+
681+// pasteLine is what x11probe -paste prints for want.
682+func pasteLine(want string) string {
683+ if len(want) <= 4096 {
684+ return fmt.Sprintf("paste %q", want)
685+ }
686+ return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want)))
687+}
688+
689+// TestXwaylandClipboard: the clipboard and the primary selection cross the
690+// X11/Wayland boundary in both directions, text only, small and INCR sized.
691+func TestXwaylandClipboard(t *testing.T) {
692+ needWlClipboard(t)
693+ e := startXwayland(t)
694+ startProbe(t, e.bin, e.display, "-title", "focus")
695+ until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil })
696+
697+ for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} {
698+ for _, size := range []int{4 << 10, 1 << 20} {
699+ name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10)
700+ text := payload(size)
701+ t.Run("W2X/"+name, func(t *testing.T) {
702+ var wargs []string
703+ if sel.w != "" {
704+ wargs = append(wargs, sel.w)
705+ }
706+ wlCopy(t, text, wargs...)
707+ until(t, "x11probe pastes the Wayland copy", func() bool {
708+ return runProbe(t, e, "-paste", sel.x) == pasteLine(text)
709+ })
710+ })
711+ t.Run("X2W/"+name, func(t *testing.T) {
712+ file := filepath.Join(t.TempDir(), "text")
713+ if err := os.WriteFile(file, []byte(text), 0o600); err != nil {
714+ t.Fatal(err)
715+ }
716+ p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s")
717+ until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") })
718+ var wargs []string
719+ if sel.w != "" {
720+ wargs = append(wargs, sel.w)
721+ }
722+ var got string
723+ until(t, "wl-paste returns the X copy", func() bool {
724+ var ok bool
725+ got, ok = wlPaste(wargs...)
726+ return ok && got == text
727+ })
728+ if got != text {
729+ t.Errorf("wl-paste differs: %d bytes", len(got))
730+ }
731+ })
732+ }
733+ }
734+
735+ t.Run("clear", func(t *testing.T) {
736+ wlCopy(t, "to be cleared")
737+ until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` })
738+ cmd := exec.Command("wl-copy", "--clear")
739+ cmd.Env = waylandEnv()
740+ if out, err := cmd.CombinedOutput(); err != nil {
741+ t.Fatalf("wl-copy --clear: %v: %s", err, out)
742+ }
743+ until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" })
744+ })
745+}
746+
747+// cursorTheme installs a one-arrow Xcursor theme of the given size and
748+// selects it through the XCURSOR_* environment.
749+func cursorTheme(t *testing.T, size int) {
750+ t.Helper()
751+ dir := t.TempDir()
752+ themeCursor(t, dir, "left_ptr", size, 1)
753+ t.Setenv("XCURSOR_PATH", dir)
754+ t.Setenv("XCURSOR_THEME", "theme")
755+ t.Setenv("XCURSOR_SIZE", strconv.Itoa(size))
756+}
757+
758+// TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme,
759+// the cursor size and a fixed scale 1 / 96 dpi.
760+func TestXwaylandXSettings(t *testing.T) {
761+ cursorTheme(t, 32)
762+ e := startXwayland(t)
763+ want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32"
764+ var got string
765+ until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
766+ if got != want {
767+ t.Errorf("-xsettings = %q, want %q", got, want)
768+ }
769+}
770+
771+// TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the
772+// DPI stays 96 even on a scale-2 output.
773+func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) {
774+ cursorTheme(t, 24)
775+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
776+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true})
777+ })
778+ var got string
779+ until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
780+ if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") {
781+ t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got)
782+ }
783+ if !strings.Contains(got, "Gtk/CursorThemeSize=24") {
784+ t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got)
785+ }
786+}
787+
788+// TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager.
789+func TestXwaylandXSettingsOff(t *testing.T) {
790+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false })
791+ // A mapped window proves the XWM is up before the absence is checked.
792+ p := startProbe(t, e.bin, e.display)
793+ until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
794+ if got := runProbe(t, e, "-xsettings"); got != "xsettings none" {
795+ t.Errorf("-xsettings = %q, want none", got)
796+ }
797+}
798+
799+// TestXwaylandRootCursor: the X background shows the themed arrow, at the
800+// theme size.
801+func TestXwaylandRootCursor(t *testing.T) {
802+ cursorTheme(t, 32)
803+ e := startXwayland(t)
804+ until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" })
805+ if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" {
806+ t.Errorf("-root-cursor = %q, want cursor 32x32", got)
807+ }
808+}
M internal/core/client.go +8 -0
@@ -70,6 +70,14 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
70 if _, w := c.screenOf(v.ID); w != nil {70 if _, w := c.screenOf(v.ID); w != nil {
71 w.SetModal(v.ID, v.Modal)71 w.SetModal(v.ID, v.Modal)
72 }72 }
73+ case ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped:
74+ changed, err := c.unmanagedEvent(ctx, v)
75+ if err != nil {
76+ return false, true
77+ }
78+ if !changed {
79+ return false, false
80+ }
73 case ports.WindowUnmapped:81 case ports.WindowUnmapped:
74 if c.unmapClient(ctx, v) != nil {82 if c.unmapClient(ctx, v) != nil {
75 return false, true83 return false, true
M internal/core/core.go +16 -5
@@ -103,9 +103,11 @@ type Core struct {
103 binds map[binding]Action103 binds map[binding]Action
104 pressed map[string]bool104 pressed map[string]bool
105 configures configures105 configures configures
106- pointer WindowID106+ // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window.
107- grab WindowID107+ placed map[WindowID]Rect
108- buttons map[uint32]bool108+ pointer WindowID
109+ grab WindowID
110+ buttons map[uint32]bool
109 // drag is the pointer drag in progress (drag.go); swallow holds the111 // drag is the pointer drag in progress (drag.go); swallow holds the
110 // buttons whose release no client must see. mods are the modifiers112 // buttons whose release no client must see. mods are the modifiers
111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest113 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
@@ -144,7 +146,11 @@ type Core struct {
144 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.146 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
145 popups map[WindowID]*popupState147 popups map[WindowID]*popupState
146 popupOrder []WindowID148 popupOrder []WindowID
147- keyboard keyboard149+ // unmanaged are the mapped override-redirect windows, unmanagedOrder
150+ // stacks them, bottom to top (unmanaged.go).
151+ unmanaged map[WindowID]*unmanagedState
152+ unmanagedOrder []WindowID
153+ keyboard keyboard
148 // activity is when core last told wayland about user input154 // activity is when core last told wayland about user input
149 // (ports.UserActivity), sent at most once per ActivityInterval.155 // (ports.UserActivity), sent at most once per ActivityInterval.
150 activity time.Time156 activity time.Time
@@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
417 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")423 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
418 }424 }
419 // A placeholder screen holds windows until the first output arrives.425 // A placeholder screen holds windows until the first output arrives.
420- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}426+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, unmanaged: map[WindowID]*unmanagedState{}, unmanagedOrder: make([]WindowID, 0, 32), ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), placed: map[WindowID]Rect{}, ping: make(chan chan struct{}), out: newOutbox()}
421 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}427 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
422 if err := c.apply(cfg); err != nil {428 if err := c.apply(cfg); err != nil {
423 return nil, err429 return nil, err
@@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
994 }1000 }
995 sc := c.screens[i]1001 sc := c.screens[i]
996 lx, ly := x-float64(sc.x), y-float64(sc.y)1002 lx, ly := x-float64(sc.x), y-float64(sc.y)
1003+ // Unmanaged windows are over everything, popups included; a locked
1004+ // pointer keeps its focus (a mouse-look game).
1005+ if id, px, py := c.unmanagedAt(sc, lx, ly); id != 0 {
1006+ return id, px, py
1007+ }
997 // Layer popups are over everything; window popups are over the windows1008 // Layer popups are over everything; window popups are over the windows
998 // only, under the top and overlay layers (as drawn).1009 // only, under the top and overlay layers (as drawn).
999 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {1010 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {
M internal/core/core_test.go +52 -3
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "reflect"6 "reflect"
7+ "regexp"
7 "slices"8 "slices"
8 "testing"9 "testing"
9 "testing/synctest"10 "testing/synctest"
@@ -38,6 +39,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
38 for {39 for {
39 switch v := receive(t, ch); v.(type) {40 switch v := receive(t, ch); v.(type) {
40 case nil, ports.SetOutputs, ports.UserActivity:41 case nil, ports.SetOutputs, ports.UserActivity:
42+ case ports.PlaceWindow:
43+ // Only Positioned (X11) windows are placed; every scenario
44+ // using this helper maps xdg windows.
45+ t.Fatalf("unexpected %+v", v)
41 default:46 default:
42 return v47 return v
43 }48 }
@@ -1028,7 +1033,7 @@ func fullscreenAtMapIgnored(t *testing.T, animated bool) {
1028 // Under fixed overflow, a window opening over a fullscreen game waits1033 // Under fixed overflow, a window opening over a fullscreen game waits
1029 // hidden: the game keeps the screen and the keyboard. An activation (a1034 // hidden: the game keeps the screen and the keyboard. An activation (a
1030 // user action in the new client) leaves fullscreen and shows both tiles;1035 // user action in the new client) leaves fullscreen and shows both tiles;
1031-// a focus move back to the game and the bind make it fullscreen again.1036+// a focus move back to the game makes it fullscreen again.
1032 func TestFixedFullscreenArrivalWaits(t *testing.T) {1037 func TestFixedFullscreenArrivalWaits(t *testing.T) {
1033 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)1038 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1034 r.mapWindow(t, 1)1039 r.mapWindow(t, 1)
@@ -1050,8 +1055,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) {
1050 break1055 break
1051 }1056 }
1052 }1057 }
1053- r.key(t, "Left", ports.ModAlt)1058+ set = r.key(t, "Left", ports.ModAlt)
1054- set = r.key(t, "f", ports.ModAlt|ports.ModShift)
1055 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {1059 if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 {
1056 t.Fatal("game again:", got, focused)1060 t.Fatal("game again:", got, focused)
1057 }1061 }
@@ -1250,3 +1254,48 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) {
1250 }1254 }
1251 1255
1252 }1256 }
1257+
1258+// A window that maps with the fullscreen state (an X11 game with
1259+// _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused,
1260+// in a tile of its own even when it was classified floating; a rule that
1261+// floats it does not change that. The grace still drops a later request.
1262+func TestMappedFullscreenHonoured(t *testing.T) {
1263+ for _, tc := range []struct {
1264+ name string
1265+ rule bool
1266+ }{{"fixed size", false}, {"floating rule", true}} {
1267+ t.Run(tc.name, func(t *testing.T) {
1268+ cfg := scrollDefaults()
1269+ cfg.Border.Width = 0
1270+ if tc.rule {
1271+ on := true
1272+ cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}}
1273+ }
1274+ client := make(chan ports.ClientEvent, 8)
1275+ output := make(chan ports.OutputEvent, 8)
1276+ commands := make(chan ports.ClientCommand, 64)
1277+ scenes := make(chan []ports.Scene, 1)
1278+ c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)})
1279+ if err != nil {
1280+ t.Fatal(err)
1281+ }
1282+ ctx, cancel := context.WithCancel(context.Background())
1283+ defer cancel()
1284+ go c.Run(ctx)
1285+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
1286+ scene(t, scenes)
1287+ client <- ports.WindowMapped{ID: 1}
1288+ scene(t, scenes)
1289+ client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true}
1290+ s := scene(t, scenes)
1291+ for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) {
1292+ s = scene(t, scenes)
1293+ }
1294+ for _, w := range s.Windows {
1295+ if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) {
1296+ t.Fatalf("fullscreen rect %+v, want the output", w.Rect)
1297+ }
1298+ }
1299+ })
1300+ }
1301+}
M internal/core/datadrag.go +1 -1
@@ -101,7 +101,7 @@ func dataDragHitChanged(ev ports.ClientEvent) bool {
101 case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,101 case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102 ports.WindowResized, ports.PopupRequest, ports.PopupMapped,102 ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103 ports.OutputPower, ports.WindowParent, ports.WindowModal,103 ports.OutputPower, ports.WindowParent, ports.WindowModal,
104- ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,104+ ports.WindowUnmapped, ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105 ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:105 ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106 return true106 return true
107 default:107 default:
M internal/core/fullscreen_left_core_test.go +1 -6
@@ -64,13 +64,8 @@ func TestCoreLeftFullscreenLatch(t *testing.T) {
64 if id := full(); id != 0 {64 if id := full(); id != 0 {
65 t.Fatalf("re-request covered: fullscreen %d", id)65 t.Fatalf("re-request covered: fullscreen %d", id)
66 }66 }
67+ // Focus back on 2 restores its fullscreen: an X11 client never re-asks.
67 client <- ports.WindowActivate{ID: 2}68 client <- ports.WindowActivate{ID: 2}
68- full()
69- client <- ports.WindowActivate{ID: 1}
70- full()
71- // The latch settled when 2 had the focus: scroll overflow honours an
72- // unfocused window's request again.
73- client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true}
74 if id := full(); id != 2 {69 if id := full(); id != 2 {
75 t.Fatalf("after refocus: fullscreen %d, want 2", id)70 t.Fatalf("after refocus: fullscreen %d, want 2", id)
76 }71 }
M internal/core/fullscreen_left_test.go +29 -0
@@ -111,3 +111,32 @@ func TestLeftFullscreenRemoved(t *testing.T) {
111 t.Fatalf("left %d", w.left)111 t.Fatalf("left %d", w.left)
112 }112 }
113 }113 }
114+
115+// Focus back on a window whose fullscreen the user left restores it, with
116+// no request from the client (X11 clients never re-ask), and allocates
117+// nothing once settled.
118+func TestLeftFullscreenRestoredOnFocus(t *testing.T) {
119+ for _, tc := range pinnedCases {
120+ t.Run(tc.name, func(t *testing.T) {
121+ m := coverMonitor(tc.overflow, tc.float)
122+ w := m.Current()
123+ w.Activate(1)
124+ if w.fullscreen != 0 || w.left != 2 {
125+ t.Fatalf("leave: fullscreen %d, left %d", w.fullscreen, w.left)
126+ }
127+ w.Activate(2)
128+ w.settleLeft()
129+ if w.fullscreen != 2 || w.left != 0 {
130+ t.Fatalf("back: fullscreen %d, left %d", w.fullscreen, w.left)
131+ }
132+ })
133+ }
134+}
135+
136+func TestSettleLeftAllocs(t *testing.T) {
137+ m := coverMonitor(OverflowScroll, false)
138+ w := m.Current()
139+ if n := testing.AllocsPerRun(100, w.settleLeft); n != 0 {
140+ t.Fatalf("settleLeft allocs %v", n)
141+ }
142+}
M internal/core/layout.go +7 -1
@@ -1435,6 +1435,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) {
1435 return1435 return
1436 }1436 }
1437 if !on {1437 if !on {
1438+ if w.left == id {
1439+ w.left = 0 // the client itself gave up: nothing to restore
1440+ }
1438 if w.fullscreen == id {1441 if w.fullscreen == id {
1439 w.endFullscreen()1442 w.endFullscreen()
1440 }1443 }
@@ -1522,7 +1525,10 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) }
1522 // settleLeft drops the latch once its window has the focus again, however1525 // settleLeft drops the latch once its window has the focus again, however
1523 // it got it; core calls it after each event.1526 // it got it; core calls it after each event.
1524 func (w *Workspace) settleLeft() {1527 func (w *Workspace) settleLeft() {
1525- if id, ok := w.Focused(); ok && id == w.left {1528+ if id, ok := w.Focused(); ok && id != 0 && id == w.left {
1529+ // Focus is back: the window gets its fullscreen back, as a client
1530+ // that re-asks would (X11 clients never do).
1531+ w.SetFullscreen(id, true)
1526 w.left = 01532 w.left = 0
1527 }1533 }
1528 }1534 }
A internal/core/place_test.go +80 -0
@@ -0,0 +1,80 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/core"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// places collects the PlaceWindow commands queued so far.
12+func places(commands chan ports.ClientCommand) (out []ports.PlaceWindow) {
13+ for len(commands) > 0 {
14+ if v, ok := (<-commands).(ports.PlaceWindow); ok {
15+ out = append(out, v)
16+ }
17+ }
18+ return out
19+}
20+
21+func TestPlaceWindow(t *testing.T) {
22+ cfg := altCmdDefaults()
23+ cfg.Border.Width = 0
24+ cfg.Layout.Gaps = 8
25+ client := make(chan ports.ClientEvent, 16)
26+ input := make(chan ports.InputEvent, 16)
27+ output := make(chan ports.OutputEvent, 8)
28+ commands := make(chan ports.ClientCommand, 256)
29+ scenes := make(chan []ports.Scene, 1)
30+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{})
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ ctx, cancel := context.WithCancel(context.Background())
35+ defer cancel()
36+ go func() { _ = c.Run(ctx) }()
37+ output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
38+ scene(t, scenes)
39+
40+ // An xdg window is never placed.
41+ client <- ports.WindowMapped{ID: 1}
42+ scene(t, scenes)
43+ if got := places(commands); len(got) != 0 {
44+ t.Fatal(got)
45+ }
46+
47+ // A positioned window is placed once on map.
48+ client <- ports.WindowMapped{ID: 2, Positioned: true}
49+ scene(t, scenes)
50+ first := places(commands)
51+ if len(first) != 1 || first[0].ID != 2 {
52+ t.Fatal(first)
53+ }
54+
55+ // The same layout sends nothing more.
56+ client <- ports.WindowMapped{ID: 1}
57+ scene(t, scenes)
58+ if got := places(commands); len(got) != 0 {
59+ t.Fatal(got)
60+ }
61+
62+ // A position change sends it again, for the positioned window only.
63+ client <- ports.WindowMapped{ID: 3}
64+ scene(t, scenes)
65+ moved := places(commands)
66+ if len(moved) != 1 || moved[0].ID != 2 || (moved[0].X == first[0].X && moved[0].Y == first[0].Y) {
67+ t.Fatal(first, moved)
68+ }
69+
70+ // Unmap prunes it: mapping again places from nothing.
71+ client <- ports.WindowUnmapped{ID: 2}
72+ scene(t, scenes)
73+ places(commands)
74+ client <- ports.WindowMapped{ID: 2, Positioned: true}
75+ scene(t, scenes)
76+ again := places(commands)
77+ if len(again) != 1 || again[0].ID != 2 {
78+ t.Fatal(again)
79+ }
80+}
M internal/core/rules_test.go +44 -0
@@ -411,3 +411,47 @@ func TestRuleWidthWithFixedOverflow(t *testing.T) {
411 t.Fatalf("%+v", s.Windows)411 t.Fatalf("%+v", s.Windows)
412 }412 }
413 }413 }
414+
415+func TestMapAtOutputPlacesOnThatOutput(t *testing.T) {
416+ r := startMulti(t, nil, left, right)
417+ r.mapApp(t, 1, "term")
418+ // DP-2 starts at layout x=200: a game mapping at its rect goes there.
419+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
420+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-2" {
421+ t.Fatalf("not on DP-2: %+v", w)
422+ }
423+}
424+
425+func TestMapAtOutputKeepsRuleMonitor(t *testing.T) {
426+ r := startMulti(t, func(c *ports.Config) {
427+ c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Monitor = "DP-1" })}
428+ }, left, right)
429+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
430+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" {
431+ t.Fatalf("rule monitor lost to the hint: %+v", w)
432+ }
433+}
434+
435+func TestMapAtOutputKeepsRuleWorkspace(t *testing.T) {
436+ r := startMulti(t, func(c *ports.Config) {
437+ c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Workspace = "1" })}
438+ }, left, right)
439+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
440+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" {
441+ t.Fatalf("rule workspace lost to the hint: %+v", w)
442+ }
443+}
444+
445+func TestRuleMatchesSteamAppClass(t *testing.T) {
446+ r := startMulti(t, func(c *ports.Config) {
447+ c.Rules = []ports.WindowRule{winRule("steam", `steam_app_\d+`, func(r *ports.WindowRule) { r.Floating = ruleOn() })}
448+ }, left)
449+ r.client <- ports.WindowMapped{ID: 1, AppID: "steam_app_892970", Positioned: true}
450+ if w := stWindow(r.stateWith(t, 1), 1); !w.Floating {
451+ t.Fatalf("steam_app rule did not match: %+v", w)
452+ }
453+ r.client <- ports.WindowMapped{ID: 2, AppID: "steam_app_x", Positioned: true}
454+ if w := stWindow(r.stateWith(t, 2), 2); w.Floating {
455+ t.Fatalf("non-numeric id matched: %+v", w)
456+ }
457+}
M internal/core/scene.go +12 -1
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
20 // layout is what is drawn; settled (same indexes) is where the20 // layout is what is drawn; settled (same indexes) is where the
21 // windows are going, and alone sizes the configures.21 // windows are going, and alone sizes the configures.
22 layout, settled := sc.shown, sc.settledLayout22 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+len(c.unmanagedOrder)+1), Layers: shownLayers(sc)}
24 if !sc.mon.previewing() {24 if !sc.mon.previewing() {
25 // Tiles stop at the panels; the overview lays out previews over25 // Tiles stop at the panels; the overview lays out previews over
26 // the whole output.26 // the whole output.
@@ -116,8 +116,19 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
116 }116 }
117 c.configures.mark(v)117 c.configures.mark(v)
118 }118 }
119+ if !p.Hidden && p.Preview == 0 && c.windows.lookup(p.ID).client.Positioned {
120+ cr := c.clientRect(ps)
121+ at := Rect{X: sc.x + cr.X, Y: sc.y + cr.Y}
122+ if old, ok := c.placed[p.ID]; !ok || old != at {
123+ if err := c.command(ctx, ports.PlaceWindow{ID: p.ID, X: at.X, Y: at.Y}); err != nil {
124+ return ports.Scene{}, err
125+ }
126+ c.placed[p.ID] = at
127+ }
128+ }
119 }129 }
120 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)130 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
131+ scene.Windows = c.sceneUnmanaged(scene.Windows, sc)
121 if icon, ok := c.dataDragIconOn(i, sc); ok {132 if icon, ok := c.dataDragIconOn(i, sc); ok {
122 // The drag icon is over everything, popups and layers included.133 // The drag icon is over everything, popups and layers included.
123 scene.Windows = append(scene.Windows, icon)134 scene.Windows = append(scene.Windows, icon)
M internal/core/spawn_placement.go +9 -1
M internal/core/transition.go +20 -0
A internal/core/unmanaged.go +117 -0
A internal/core/unmanaged_test.go +145 -0
M internal/ports/client_commands.go +10 -0
M internal/ports/client_events.go +37 -0
M internal/ports/config.go +35 -3

Comments0 open / 0

no comments yet.

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
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close