#21 WIP: feat(xwayland): add built-in Xwayland launcher
- state
- draft
- branch
- feat/xwayland-builtin → main
- opened
- bnema ·
- updated
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 on | |
| 2 | - | // demand: neferwl owns the X11 display sockets and starts the satellite | |
| 3 | - | // when the first X11 client connects, and again after it exits. | |
| 1 | + | // Package xwayland starts an X11 backend on demand. NeferWL owns the display | |
| 2 | + | // sockets and starts the selected backend when the first client connects. | |
| 4 | 3 | package xwayland | |
| 5 | 4 | ||
| 6 | 5 | import ( | |
| @@ -17,15 +16,26 @@ import ( | |||
| 17 | 16 | "syscall" | |
| 18 | 17 | "time" | |
| 19 | 18 | ||
| 19 | + | "github.com/bnema/neferwl/internal/ports" | |
| 20 | 20 | "github.com/bnema/zerowrap" | |
| 21 | 21 | "golang.org/x/sys/unix" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | // Options configures the X11 display. | |
| 25 | 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 | 37 | 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 | 39 | Env []string | |
| 30 | 40 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | |
| 31 | 41 | // and /tmp outside tests. | |
| @@ -35,8 +45,9 @@ type Options struct { | |||
| 35 | 45 | 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 | 49 | type Display struct { | |
| 50 | + | starter starter | |
| 40 | 51 | opts Options | |
| 41 | 52 | n int | |
| 42 | 53 | sockets []*os.File // unix socket, then the abstract one | |
| @@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) { | |||
| 81 | 92 | _ = os.Remove(lock) | |
| 82 | 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 | 99 | if err := d.listen(); err != nil { | |
| 86 | 100 | last = err | |
| 87 | 101 | d.Close() | |
| @@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error { | |||
| 212 | 226 | return err | |
| 213 | 227 | } | |
| 214 | 228 | start := time.Now() | |
| 215 | - | stderr := &logWriter{log: d.opts.Log} | |
| 216 | - | err := d.serve(ctx, stderr) | |
| 229 | + | stderr := &logWriter{log: d.opts.Log, backend: d.backendName()} | |
| 230 | + | err := d.starter.start(ctx, d, stderr) | |
| 217 | 231 | if ctx.Err() != nil { | |
| 218 | 232 | return nil | |
| 219 | 233 | } | |
| 220 | 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 | 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 | 239 | // Clients the satellite never accepted would wake us at once: a | |
| 226 | 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 | 275 | return ctx.Err() | |
| 262 | 276 | } | |
| 263 | 277 | ||
| 264 | - | // serve runs the satellite on the sockets until it exits or ctx ends. | |
| 265 | - | func (d *Display) serve(ctx context.Context, stderr *logWriter) error { | |
| 278 | + | // starter runs a backend on the reserved sockets until it exits or ctx ends. | |
| 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 | 286 | args := []string{d.Name()} | |
| 267 | 287 | for i := range d.sockets { | |
| 268 | 288 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | |
| @@ -318,10 +338,11 @@ func (d *Display) dropPending() { | |||
| 318 | 338 | // logWriter logs the satellite's stderr line by line and keeps the last | |
| 319 | 339 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | |
| 320 | 340 | type logWriter struct { | |
| 321 | - | mu sync.Mutex | |
| 322 | - | log zerowrap.Logger | |
| 323 | - | line []byte | |
| 324 | - | last []string | |
| 341 | + | backend string | |
| 342 | + | mu sync.Mutex | |
| 343 | + | log zerowrap.Logger | |
| 344 | + | line []byte | |
| 345 | + | last []string | |
| 325 | 346 | } | |
| 326 | 347 | ||
| 327 | 348 | // tail is the last lines written. | |
| @@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 342 | 363 | } | |
| 343 | 364 | if i > 0 { | |
| 344 | 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 | 367 | if w.last = append(w.last, line); len(w.last) > 5 { | |
| 347 | 368 | w.last = w.last[1:] | |
| 348 | 369 | } | |
| @@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||
| 354 | 375 | } | |
| 355 | 376 | 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 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 14 | 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 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | + | "os/exec" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 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 returns | |
| 192 | + | // openXwayland reserves an X11 display for the selected backend, or returns | |
| 192 | 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 | - | bin := opts.Config.Xwayland | |
| 195 | - | if bin == "" || opts.NoXwayland { | |
| 194 | + | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display { | |
| 195 | + | cfg := opts.Config.Xwayland | |
| 196 | + | bin := cfg.Binary | |
| 197 | + | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | |
| 196 | 198 | 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 | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | |
| 200 | 207 | 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 | 210 | if err != nil { | |
| 204 | 211 | log.Warn().Err(err).Msg("X11 display disabled") | |
| 205 | 212 | 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 | 138 | themeCursors.m[key] = out | |
| 139 | 139 | 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 | 140 | RepeatRate: opts.Config.Keyboard.RepeatRate, | |
| 141 | 141 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | |
| 142 | 142 | LogDamage: logging.Enabled(ctx, "wayland-damage"), | |
| 143 | + | Xwayland: xwaylandSettings(opts.Config.Xwayland), | |
| 143 | 144 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | |
| 144 | 145 | return s, err | |
| 145 | 146 | } | |
| @@ -152,7 +153,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 152 | 153 | s.statePath = statePath | |
| 153 | 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 | 157 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | |
| 157 | 158 | } | |
| 158 | 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 | 70 | if _, w := c.screenOf(v.ID); w != nil { | |
| 71 | 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 | 81 | case ports.WindowUnmapped: | |
| 74 | 82 | if c.unmapClient(ctx, v) != nil { | |
| 75 | 83 | return false, true | |
M internal/core/core.go +16 -5
| @@ -103,9 +103,11 @@ type Core struct { | |||
| 103 | 103 | binds map[binding]Action | |
| 104 | 104 | pressed map[string]bool | |
| 105 | 105 | configures configures | |
| 106 | - | pointer WindowID | |
| 107 | - | grab WindowID | |
| 108 | - | buttons map[uint32]bool | |
| 106 | + | // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window. | |
| 107 | + | placed map[WindowID]Rect | |
| 108 | + | pointer WindowID | |
| 109 | + | grab WindowID | |
| 110 | + | buttons map[uint32]bool | |
| 109 | 111 | // drag is the pointer drag in progress (drag.go); swallow holds the | |
| 110 | 112 | // buttons whose release no client must see. mods are the modifiers | |
| 111 | 113 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | |
| @@ -144,7 +146,11 @@ type Core struct { | |||
| 144 | 146 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 145 | 147 | popups map[WindowID]*popupState | |
| 146 | 148 | popupOrder []WindowID | |
| 147 | - | keyboard keyboard | |
| 149 | + | // 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 | 154 | // activity is when core last told wayland about user input | |
| 149 | 155 | // (ports.UserActivity), sent at most once per ActivityInterval. | |
| 150 | 156 | activity time.Time | |
| @@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) { | |||
| 417 | 423 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | |
| 418 | 424 | } | |
| 419 | 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 | 427 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | |
| 422 | 428 | if err := c.apply(cfg); err != nil { | |
| 423 | 429 | return nil, err | |
| @@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 994 | 1000 | } | |
| 995 | 1001 | sc := c.screens[i] | |
| 996 | 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 | 1008 | // Layer popups are over everything; window popups are over the windows | |
| 998 | 1009 | // only, under the top and overlay layers (as drawn). | |
| 999 | 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 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "reflect" | |
| 7 | + | "regexp" | |
| 7 | 8 | "slices" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "testing/synctest" | |
| @@ -38,6 +39,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||
| 38 | 39 | for { | |
| 39 | 40 | switch v := receive(t, ch); v.(type) { | |
| 40 | 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 | 46 | default: | |
| 42 | 47 | return v | |
| 43 | 48 | } | |
| @@ -1028,7 +1033,7 @@ func fullscreenAtMapIgnored(t *testing.T, animated bool) { | |||
| 1028 | 1033 | // Under fixed overflow, a window opening over a fullscreen game waits | |
| 1029 | 1034 | // hidden: the game keeps the screen and the keyboard. An activation (a | |
| 1030 | 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 | 1037 | func TestFixedFullscreenArrivalWaits(t *testing.T) { | |
| 1033 | 1038 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | |
| 1034 | 1039 | r.mapWindow(t, 1) | |
| @@ -1050,8 +1055,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||
| 1050 | 1055 | break | |
| 1051 | 1056 | } | |
| 1052 | 1057 | } | |
| 1053 | - | r.key(t, "Left", ports.ModAlt) | |
| 1054 | - | set = r.key(t, "f", ports.ModAlt|ports.ModShift) | |
| 1058 | + | set = r.key(t, "Left", ports.ModAlt) | |
| 1055 | 1059 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | |
| 1056 | 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 | 101 | case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped, | |
| 102 | 102 | ports.WindowResized, ports.PopupRequest, ports.PopupMapped, | |
| 103 | 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 | 105 | ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate: | |
| 106 | 106 | return true | |
| 107 | 107 | default: | |
M internal/core/fullscreen_left_core_test.go +1 -6
| @@ -64,13 +64,8 @@ func TestCoreLeftFullscreenLatch(t *testing.T) { | |||
| 64 | 64 | if id := full(); id != 0 { | |
| 65 | 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 | 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 | 69 | if id := full(); id != 2 { | |
| 75 | 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 | 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 | 1435 | return | |
| 1436 | 1436 | } | |
| 1437 | 1437 | if !on { | |
| 1438 | + | if w.left == id { | |
| 1439 | + | w.left = 0 // the client itself gave up: nothing to restore | |
| 1440 | + | } | |
| 1438 | 1441 | if w.fullscreen == id { | |
| 1439 | 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 | 1525 | // settleLeft drops the latch once its window has the focus again, however | |
| 1523 | 1526 | // it got it; core calls it after each event. | |
| 1524 | 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 | 1532 | 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 | 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 | 20 | // layout is what is drawn; settled (same indexes) is where the | |
| 21 | 21 | // windows are going, and alone sizes the configures. | |
| 22 | 22 | 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 | 24 | if !sc.mon.previewing() { | |
| 25 | 25 | // Tiles stop at the panels; the overview lays out previews over | |
| 26 | 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 | 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 | 130 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 131 | + | scene.Windows = c.sceneUnmanaged(scene.Windows, sc) | |
| 121 | 132 | if icon, ok := c.dataDragIconOn(i, sc); ok { | |
| 122 | 133 | // The drag icon is over everything, popups and layers included. | |
| 123 | 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.