#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 | 1 | + | // Package xwayland starts an X11 backend on demand. NeferWL owns the display |
| 2 | - | // demand: neferwl owns the X11 display sockets and starts the satellite | 2 | + | // sockets and starts the selected backend when the first client connects. |
| 3 | - | // when the first X11 client connects, and again after it exits. | |||
| 4 | package xwayland | 3 | package xwayland | ||
| 5 | 4 | ||||
| 6 | import ( | 5 | import ( | ||
| @@ -17,15 +16,26 @@ import ( | |||||
| 17 | "syscall" | 16 | "syscall" | ||
| 18 | "time" | 17 | "time" | ||
| 19 | 18 | ||||
| 19 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 20 | "github.com/bnema/zerowrap" | 20 | "github.com/bnema/zerowrap" | ||
| 21 | "golang.org/x/sys/unix" | 21 | "golang.org/x/sys/unix" | ||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | // Options configures the X11 display. | 24 | // Options configures the X11 display. | ||
| 25 | type Options struct { | 25 | type Options struct { | ||
| 26 | - | // Binary is the xwayland-satellite executable. | 26 | + | Backend ports.XwaylandBackend |
| 27 | + | // Attach takes ownership of the Wayland descriptor right after the backend | |||
| 28 | + | // starts. | |||
| 29 | + | Attach func(wlFD int) | |||
| 30 | + | // AttachWM takes ownership of the window manager descriptor once the | |||
| 31 | + | // backend is ready. It may block to bootstrap the X11 connection; ctx ends | |||
| 32 | + | // when the backend exits or the run is cancelled, and the bootstrap must | |||
| 33 | + | // then give up. The returned stop (nil for none) is called before the run | |||
| 34 | + | // ends, on backend exit or context cancellation. | |||
| 35 | + | AttachWM func(ctx context.Context, wmFD int) (stop func()) | |||
| 36 | + | // Binary is the selected backend executable. | |||
| 27 | Binary string | 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 | Env []string | 39 | Env []string | ||
| 30 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | 40 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | ||
| 31 | // and /tmp outside tests. | 41 | // and /tmp outside tests. | ||
| @@ -35,8 +45,9 @@ type Options struct { | |||||
| 35 | Log zerowrap.Logger | 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 | type Display struct { | 49 | type Display struct { | ||
| 50 | + | starter starter | |||
| 40 | opts Options | 51 | opts Options | ||
| 41 | n int | 52 | n int | ||
| 42 | sockets []*os.File // unix socket, then the abstract one | 53 | sockets []*os.File // unix socket, then the abstract one | ||
| @@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) { | |||||
| 81 | _ = os.Remove(lock) | 92 | _ = os.Remove(lock) | ||
| 82 | return nil, fmt.Errorf("write X11 lock: %w", err) | 93 | return nil, fmt.Errorf("write X11 lock: %w", err) | ||
| 83 | } | 94 | } | ||
| 84 | - | d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))} | 95 | + | d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))} |
| 96 | + | if opts.Backend == ports.XwaylandBuiltin { | |||
| 97 | + | d.starter = builtinStarter{} | |||
| 98 | + | } | |||
| 85 | if err := d.listen(); err != nil { | 99 | if err := d.listen(); err != nil { | ||
| 86 | last = err | 100 | last = err | ||
| 87 | d.Close() | 101 | d.Close() | ||
| @@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error { | |||||
| 212 | return err | 226 | return err | ||
| 213 | } | 227 | } | ||
| 214 | start := time.Now() | 228 | start := time.Now() | ||
| 215 | - | stderr := &logWriter{log: d.opts.Log} | 229 | + | stderr := &logWriter{log: d.opts.Log, backend: d.backendName()} |
| 216 | - | err := d.serve(ctx, stderr) | 230 | + | err := d.starter.start(ctx, d, stderr) |
| 217 | if ctx.Err() != nil { | 231 | if ctx.Err() != nil { | ||
| 218 | return nil | 232 | return nil | ||
| 219 | } | 233 | } | ||
| 220 | if err != nil { | 234 | if err != nil { | ||
| 221 | - | d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited") | 235 | + | d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited") |
| 222 | } else { | 236 | } else { | ||
| 223 | - | d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited") | 237 | + | d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited") |
| 224 | } | 238 | } | ||
| 225 | // Clients the satellite never accepted would wake us at once: a | 239 | // Clients the satellite never accepted would wake us at once: a | ||
| 226 | // satellite that cannot start must not respawn in a loop. | 240 | // satellite that cannot start must not respawn in a loop. | ||
| @@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error { | |||||
| 261 | return ctx.Err() | 275 | return ctx.Err() | ||
| 262 | } | 276 | } | ||
| 263 | 277 | ||||
| 264 | - | // serve runs the satellite on the sockets until it exits or ctx ends. | 278 | + | // starter runs a backend on the reserved sockets until it exits or ctx ends. |
| 265 | - | func (d *Display) serve(ctx context.Context, stderr *logWriter) error { | 279 | + | type starter interface { |
| 280 | + | start(ctx context.Context, d *Display, stderr *logWriter) error | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | type satelliteStarter struct{} | |||
| 284 | + | ||||
| 285 | + | func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | |||
| 266 | args := []string{d.Name()} | 286 | args := []string{d.Name()} | ||
| 267 | for i := range d.sockets { | 287 | for i := range d.sockets { | ||
| 268 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | 288 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | ||
| @@ -318,10 +338,11 @@ func (d *Display) dropPending() { | |||||
| 318 | // logWriter logs the satellite's stderr line by line and keeps the last | 338 | // logWriter logs the satellite's stderr line by line and keeps the last | ||
| 319 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | 339 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | ||
| 320 | type logWriter struct { | 340 | type logWriter struct { | ||
| 321 | - | mu sync.Mutex | 341 | + | backend string |
| 322 | - | log zerowrap.Logger | 342 | + | mu sync.Mutex |
| 323 | - | line []byte | 343 | + | log zerowrap.Logger |
| 324 | - | last []string | 344 | + | line []byte |
| 345 | + | last []string | |||
| 325 | } | 346 | } | ||
| 326 | 347 | ||||
| 327 | // tail is the last lines written. | 348 | // tail is the last lines written. | ||
| @@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 342 | } | 363 | } | ||
| 343 | if i > 0 { | 364 | if i > 0 { | ||
| 344 | line := string(w.line[:i]) | 365 | line := string(w.line[:i]) | ||
| 345 | - | w.log.Debug().Str("line", line).Msg("xwayland-satellite") | 366 | + | w.log.Debug().Str("line", line).Msg(w.backend) |
| 346 | if w.last = append(w.last, line); len(w.last) > 5 { | 367 | if w.last = append(w.last, line); len(w.last) > 5 { | ||
| 347 | w.last = w.last[1:] | 368 | w.last = w.last[1:] | ||
| 348 | } | 369 | } | ||
| @@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 354 | } | 375 | } | ||
| 355 | return len(p), nil | 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 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/neferwl/internal/adapters/logging" | 13 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 14 | "golang.org/x/sys/unix" | 16 | "golang.org/x/sys/unix" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| @@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) { | |||||
| 270 | } | 272 | } | ||
| 271 | } | 273 | } | ||
| 272 | } | 274 | } | ||
| 275 | + | ||||
| 276 | + | func TestStarterSelection(t *testing.T) { | |||
| 277 | + | for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} { | |||
| 278 | + | opts := testOptions(t, "true") | |||
| 279 | + | opts.Backend = backend | |||
| 280 | + | d, err := Open(opts) | |||
| 281 | + | if err != nil { | |||
| 282 | + | t.Fatal(err) | |||
| 283 | + | } | |||
| 284 | + | defer d.Close() | |||
| 285 | + | switch d.starter.(type) { | |||
| 286 | + | case satelliteStarter: | |||
| 287 | + | if backend != ports.XwaylandSatellite { | |||
| 288 | + | t.Fatal("satellite selected") | |||
| 289 | + | } | |||
| 290 | + | case builtinStarter: | |||
| 291 | + | if backend != ports.XwaylandBuiltin { | |||
| 292 | + | t.Fatal("builtin selected") | |||
| 293 | + | } | |||
| 294 | + | default: | |||
| 295 | + | t.Fatalf("unexpected starter %T", d.starter) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestRunCallsStarter(t *testing.T) { | |||
| 301 | + | d, err := Open(testOptions(t, "unused")) | |||
| 302 | + | if err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | defer d.Close() | |||
| 306 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 307 | + | defer cancel() | |||
| 308 | + | m := newMockstarter(t) | |||
| 309 | + | d.starter = m | |||
| 310 | + | m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error { | |||
| 311 | + | if got != d || stderr == nil { | |||
| 312 | + | t.Error("starter arguments") | |||
| 313 | + | } | |||
| 314 | + | cancel() | |||
| 315 | + | return ctx.Err() | |||
| 316 | + | }).Once() | |||
| 317 | + | done := make(chan error, 1) | |||
| 318 | + | go func() { done <- d.Run(ctx) }() | |||
| 319 | + | unix.Close(dial(t, d.socket)) | |||
| 320 | + | select { | |||
| 321 | + | case err := <-done: | |||
| 322 | + | if err != nil { | |||
| 323 | + | t.Fatal(err) | |||
| 324 | + | } | |||
| 325 | + | case <-time.After(3 * time.Second): | |||
| 326 | + | t.Fatal("Run did not stop") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func TestWaitReady(t *testing.T) { | |||
| 331 | + | for _, tt := range []struct { | |||
| 332 | + | name, data string | |||
| 333 | + | wantErr bool | |||
| 334 | + | }{ | |||
| 335 | + | {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true}, | |||
| 336 | + | } { | |||
| 337 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 338 | + | var p [2]int | |||
| 339 | + | if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil { | |||
| 340 | + | t.Fatal(err) | |||
| 341 | + | } | |||
| 342 | + | defer unix.Close(p[0]) | |||
| 343 | + | if tt.data != "" { | |||
| 344 | + | if _, err := unix.Write(p[1], []byte(tt.data)); err != nil { | |||
| 345 | + | t.Fatal(err) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | unix.Close(p[1]) | |||
| 349 | + | joined, err := waitReady(t.Context(), p[0], make(chan error)) | |||
| 350 | + | if joined || (err != nil) != tt.wantErr { | |||
| 351 | + | t.Fatalf("joined=%v err=%v", joined, err) | |||
| 352 | + | } | |||
| 353 | + | }) | |||
| 354 | + | } | |||
| 355 | + | t.Run("exit", func(t *testing.T) { | |||
| 356 | + | done := make(chan error, 1) | |||
| 357 | + | done <- errors.New("exit") | |||
| 358 | + | joined, err := waitReady(t.Context(), -1, done) | |||
| 359 | + | if !joined || err == nil { | |||
| 360 | + | t.Fatal(joined, err) | |||
| 361 | + | } | |||
| 362 | + | }) | |||
| 363 | + | t.Run("cancel", func(t *testing.T) { | |||
| 364 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 365 | + | cancel() | |||
| 366 | + | joined, err := waitReady(ctx, -1, make(chan error)) | |||
| 367 | + | if joined || !errors.Is(err, context.Canceled) { | |||
| 368 | + | t.Fatal(joined, err) | |||
| 369 | + | } | |||
| 370 | + | }) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // readyScript stands in for Xwayland: after a short delay it creates a marker | |||
| 374 | + | // file and reports readiness on the -displayfd descriptor when ready is true, | |||
| 375 | + | // then runs tail. | |||
| 376 | + | func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) { | |||
| 377 | + | t.Helper() | |||
| 378 | + | bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready") | |||
| 379 | + | script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" + | |||
| 380 | + | "sleep 0.3\n" | |||
| 381 | + | if ready { | |||
| 382 | + | script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n" | |||
| 383 | + | } | |||
| 384 | + | script += tail + "\n" | |||
| 385 | + | if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { | |||
| 386 | + | t.Fatal(err) | |||
| 387 | + | } | |||
| 388 | + | return bin, marker | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) { | |||
| 392 | + | t.Helper() | |||
| 393 | + | opts := testOptions(t, "") | |||
| 394 | + | opts.Backend = ports.XwaylandBuiltin | |||
| 395 | + | var marker string | |||
| 396 | + | opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail) | |||
| 397 | + | exists := func() bool { _, err := os.Stat(marker); return err == nil } | |||
| 398 | + | opts.Attach = func(wlFD int) { | |||
| 399 | + | if exists() { | |||
| 400 | + | t.Error("Attach ran after readiness") | |||
| 401 | + | } | |||
| 402 | + | events = append(events, "attach") | |||
| 403 | + | unix.Close(wlFD) | |||
| 404 | + | } | |||
| 405 | + | opts.AttachWM = func(ctx context.Context, wmFD int) func() { | |||
| 406 | + | if !exists() { | |||
| 407 | + | t.Error("AttachWM ran before readiness") | |||
| 408 | + | } | |||
| 409 | + | events = append(events, "attachWM") | |||
| 410 | + | attachWM(ctx, wmFD) | |||
| 411 | + | return func() { events = append(events, "stop") } | |||
| 412 | + | } | |||
| 413 | + | d, err := Open(opts) | |||
| 414 | + | if err != nil { | |||
| 415 | + | t.Fatal(err) | |||
| 416 | + | } | |||
| 417 | + | defer d.Close() | |||
| 418 | + | _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()}) | |||
| 419 | + | return append(events, "returned") | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // AttachWM runs only after -displayfd readiness; stop runs before start | |||
| 423 | + | // returns, whether the process exits or the context ends. | |||
| 424 | + | func TestBuiltinAttachWMAfterReadiness(t *testing.T) { | |||
| 425 | + | for name, tc := range map[string]struct { | |||
| 426 | + | tail string | |||
| 427 | + | cancel bool | |||
| 428 | + | }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} { | |||
| 429 | + | t.Run(name, func(t *testing.T) { | |||
| 430 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 431 | + | defer cancel() | |||
| 432 | + | events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) { | |||
| 433 | + | if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil { | |||
| 434 | + | t.Errorf("wmFD not open: %v", err) | |||
| 435 | + | } | |||
| 436 | + | unix.Close(wmFD) | |||
| 437 | + | if tc.cancel { | |||
| 438 | + | go func() { time.Sleep(200 * time.Millisecond); cancel() }() | |||
| 439 | + | } | |||
| 440 | + | }) | |||
| 441 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |||
| 442 | + | t.Fatalf("events %s", got) | |||
| 443 | + | } | |||
| 444 | + | }) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // A process that exits before reporting readiness never reaches AttachWM. | |||
| 449 | + | func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) { | |||
| 450 | + | events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {}) | |||
| 451 | + | if got := strings.Join(events, ","); got != "attach,returned" { | |||
| 452 | + | t.Fatalf("events %s", got) | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | // The AttachWM context ends when the process dies, so a bootstrap against a | |||
| 457 | + | // dead Xwayland gives up at once instead of waiting out its handshake. | |||
| 458 | + | func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) { | |||
| 459 | + | ended := make(chan error, 1) | |||
| 460 | + | start := time.Now() | |||
| 461 | + | events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) { | |||
| 462 | + | defer unix.Close(wmFD) | |||
| 463 | + | select { | |||
| 464 | + | case <-ctx.Done(): | |||
| 465 | + | ended <- ctx.Err() | |||
| 466 | + | case <-time.After(5 * time.Second): | |||
| 467 | + | ended <- nil | |||
| 468 | + | } | |||
| 469 | + | }) | |||
| 470 | + | if err := <-ended; err == nil { | |||
| 471 | + | t.Fatal("AttachWM context did not end with the process") | |||
| 472 | + | } | |||
| 473 | + | if time.Since(start) > 3*time.Second { | |||
| 474 | + | t.Fatalf("took %v", time.Since(start)) | |||
| 475 | + | } | |||
| 476 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |||
| 477 | + | t.Fatalf("events %s", got) | |||
| 478 | + | } | |||
| 479 | + | } | |||
M internal/app/app.go +13 -6
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "os/exec" | |||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/neferwl/internal/adapters/logging" | 11 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| @@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) { | |||||
| 188 | } | 189 | } | ||
| 189 | } | 190 | } | ||
| 190 | 191 | ||||
| 191 | - | // openXwayland reserves an X11 display for xwayland-satellite, or returns | 192 | + | // openXwayland reserves an X11 display for the selected backend, or returns |
| 192 | // nil when X11 is off or unavailable: the session runs without it. | 193 | // nil when X11 is off or unavailable: the session runs without it. | ||
| 193 | - | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display { | 194 | + | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display { |
| 194 | - | bin := opts.Config.Xwayland | 195 | + | cfg := opts.Config.Xwayland |
| 195 | - | if bin == "" || opts.NoXwayland { | 196 | + | bin := cfg.Binary |
| 197 | + | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | |||
| 196 | return nil | 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 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | ||
| 200 | return nil | 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 | if err != nil { | 210 | if err != nil { | ||
| 204 | log.Warn().Err(err).Msg("X11 display disabled") | 211 | log.Warn().Err(err).Msg("X11 display disabled") | ||
| 205 | return nil | 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 | themeCursors.m[key] = out | 138 | themeCursors.m[key] = out | ||
| 139 | return out, nil | 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 | RepeatRate: opts.Config.Keyboard.RepeatRate, | 140 | RepeatRate: opts.Config.Keyboard.RepeatRate, | ||
| 141 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | 141 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | ||
| 142 | LogDamage: logging.Enabled(ctx, "wayland-damage"), | 142 | LogDamage: logging.Enabled(ctx, "wayland-damage"), | ||
| 143 | + | Xwayland: xwaylandSettings(opts.Config.Xwayland), | |||
| 143 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | 144 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | ||
| 144 | return s, err | 145 | return s, err | ||
| 145 | } | 146 | } | ||
| @@ -152,7 +153,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 152 | s.statePath = statePath | 153 | s.statePath = statePath | ||
| 153 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | 154 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | ||
| 154 | } | 155 | } | ||
| 155 | - | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil { | 156 | + | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil { |
| 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | 157 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | ||
| 157 | } | 158 | } | ||
| 158 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | 159 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | ||
A internal/app/testdata/x11probe/main.go +692 -0
| @@ -0,0 +1,692 @@ | |||||
| 1 | + | // Command x11probe is a minimal X11 client for the Xwayland end-to-end test. | |||
| 2 | + | // | |||
| 3 | + | // It opens one window filled with solid green, prints "mapped WxH" after the | |||
| 4 | + | // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the | |||
| 5 | + | // window manager sends WM_DELETE_WINDOW. | |||
| 6 | + | // | |||
| 7 | + | // With -override it also maps a magenta override-redirect window after the | |||
| 8 | + | // main one is mapped (prints "override mapped") and unmaps it after | |||
| 9 | + | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | |||
| 10 | + | // it unmaps it instead when the main window loses focus (prints "dismissed"). | |||
| 11 | + | // | |||
| 12 | + | // With -initial-fullscreen the window maps already fullscreen and fixed-size. | |||
| 13 | + | // With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN | |||
| 14 | + | // -fullscreen-delay after the window is mapped (0: right at the map) and | |||
| 15 | + | // prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to | |||
| 16 | + | // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab | |||
| 17 | + | // calls XGrabKeyboard on the window once it is mapped (prints "grab"). | |||
| 18 | + | // -at X,Y creates the window at that root position. | |||
| 19 | + | // | |||
| 20 | + | // Four more modes run without a window and exit when done. | |||
| 21 | + | // -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for | |||
| 22 | + | // -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above | |||
| 23 | + | // 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR | |||
| 24 | + | // aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or | |||
| 25 | + | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, Gdk/WindowScalingFactor, Gdk/UnscaledDPI, | |||
| 26 | + | // Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none". | |||
| 27 | + | // -root-cursor prints "cursor WxH" of the XFixes cursor image. | |||
| 28 | + | package main | |||
| 29 | + | ||||
| 30 | + | import ( | |||
| 31 | + | "crypto/sha256" | |||
| 32 | + | "encoding/binary" | |||
| 33 | + | "flag" | |||
| 34 | + | "fmt" | |||
| 35 | + | "os" | |||
| 36 | + | "strconv" | |||
| 37 | + | "strings" | |||
| 38 | + | "time" | |||
| 39 | + | ||||
| 40 | + | "golang.org/x/sys/unix" | |||
| 41 | + | ||||
| 42 | + | "bnema.dev/xturbo" | |||
| 43 | + | "bnema.dev/xturbo/shape" | |||
| 44 | + | "bnema.dev/xturbo/xfixes" | |||
| 45 | + | "bnema.dev/xturbo/xproto" | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | const ( | |||
| 49 | + | green = 0x00ff00 | |||
| 50 | + | magenta = 0xff00ff | |||
| 51 | + | ) | |||
| 52 | + | ||||
| 53 | + | func main() { | |||
| 54 | + | if err := run(); err != nil { | |||
| 55 | + | fmt.Fprintln(os.Stderr, "x11probe:", err) | |||
| 56 | + | os.Exit(1) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func run() error { | |||
| 61 | + | class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)") | |||
| 62 | + | title := flag.String("title", "probe", "_NET_WM_NAME") | |||
| 63 | + | size := flag.String("size", "300x200", "window size WxH") | |||
| 64 | + | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | |||
| 65 | + | override := flag.Bool("override", false, "map a magenta override-redirect window after the main one") | |||
| 66 | + | overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates") | |||
| 67 | + | overrideSize := flag.String("override-size", "120x80", "override window size WxH") | |||
| 68 | + | overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)") | |||
| 69 | + | overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip") | |||
| 70 | + | overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape") | |||
| 71 | + | dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus") | |||
| 72 | + | fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped") | |||
| 73 | + | fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request") | |||
| 74 | + | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | |||
| 75 | + | initialFullscreen := flag.Bool("initial-fullscreen", false, "set _NET_WM_STATE_FULLSCREEN and fixed-size WM_NORMAL_HINTS (min=max=size) before mapping") | |||
| 76 | + | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | |||
| 77 | + | at := flag.String("at", "0,0", "window position X,Y in root coordinates") | |||
| 78 | + | copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window") | |||
| 79 | + | copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY") | |||
| 80 | + | copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection") | |||
| 81 | + | paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window") | |||
| 82 | + | xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window") | |||
| 83 | + | rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window") | |||
| 84 | + | flag.Parse() | |||
| 85 | + | if *copyText != "" || *paste != "" || *xsettings || *rootCursor { | |||
| 86 | + | return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor) | |||
| 87 | + | } | |||
| 88 | + | atX, atY, okAt := strings.Cut(*at, ",") | |||
| 89 | + | if !okAt { | |||
| 90 | + | return fmt.Errorf("bad -at %q", *at) | |||
| 91 | + | } | |||
| 92 | + | w, h, ok := parseSize(*size) | |||
| 93 | + | if !ok { | |||
| 94 | + | return fmt.Errorf("bad -size %q", *size) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | c, err := xturbo.Dial("") // $DISPLAY | |||
| 98 | + | if err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | defer c.Close() | |||
| 102 | + | ||||
| 103 | + | var atoms [9]uint32 | |||
| 104 | + | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING", | |||
| 105 | + | "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | |||
| 109 | + | typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6] | |||
| 110 | + | netWMState, netWMFullscreen := atoms[7], atoms[8] | |||
| 111 | + | ||||
| 112 | + | b := c.Buf() | |||
| 113 | + | xwin, err := c.NewXID() | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | xgc, err := c.NewXID() | |||
| 118 | + | if err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | win, gc := xproto.Window(xwin), xproto.Gcontext(xgc) | |||
| 122 | + | ||||
| 123 | + | var v xproto.CreateWindowValues | |||
| 124 | + | v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange) | |||
| 125 | + | xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v) | |||
| 126 | + | ||||
| 127 | + | wmClass := []byte("x11probe\x00" + *class + "\x00") | |||
| 128 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass) | |||
| 129 | + | name := []byte(*title) | |||
| 130 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name) | |||
| 131 | + | var del [4]byte | |||
| 132 | + | binary.LittleEndian.PutUint32(del[:], uint32(deleteWin)) | |||
| 133 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:]) | |||
| 134 | + | ||||
| 135 | + | var gv xproto.CreateGCValues | |||
| 136 | + | gv.SetForeground(green) | |||
| 137 | + | xproto.CreateGC(b, gc, xproto.Drawable(win), &gv) | |||
| 138 | + | if err := c.Flush(); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | time.Sleep(*sleep) | |||
| 142 | + | if *at != "0,0" { | |||
| 143 | + | // Like games, ask for the rect before the map (a ConfigureRequest the | |||
| 144 | + | // window manager sees as the output hint). | |||
| 145 | + | var cv xproto.ConfigureWindowValues | |||
| 146 | + | cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h)) | |||
| 147 | + | xproto.ConfigureWindow(b, win, &cv) | |||
| 148 | + | } | |||
| 149 | + | if *initialFullscreen { | |||
| 150 | + | // Like a Proton game: fullscreen state and a fixed size, both set | |||
| 151 | + | // before the map. | |||
| 152 | + | var st [4]byte | |||
| 153 | + | binary.LittleEndian.PutUint32(st[:], netWMFullscreen) | |||
| 154 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.Atom(netWMState), xproto.AtomAtom, 32, 1, st[:]) | |||
| 155 | + | var nh [72]byte // WM_SIZE_HINTS: flags, x, y, w, h, min, max, ... | |||
| 156 | + | binary.LittleEndian.PutUint32(nh[0:], 16|32) // PMinSize|PMaxSize | |||
| 157 | + | for _, off := range []int{20, 28} { | |||
| 158 | + | binary.LittleEndian.PutUint32(nh[off:], uint32(w)) | |||
| 159 | + | binary.LittleEndian.PutUint32(nh[off+4:], uint32(h)) | |||
| 160 | + | } | |||
| 161 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmNormalHints, xproto.AtomWmSizeHints, 32, 18, nh[:]) | |||
| 162 | + | } | |||
| 163 | + | xproto.MapWindow(b, win) | |||
| 164 | + | ||||
| 165 | + | // The override-redirect window is created once the main window is mapped. | |||
| 166 | + | var ovWin xproto.Window | |||
| 167 | + | var ovAt, ovSz [2]int | |||
| 168 | + | if *override { | |||
| 169 | + | var ok1, ok2 bool | |||
| 170 | + | x, y, found := strings.Cut(*overrideAt, ",") | |||
| 171 | + | ovAt[0], ovAt[1] = atoi(x), atoi(y) | |||
| 172 | + | ok1 = found | |||
| 173 | + | ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize) | |||
| 174 | + | if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") { | |||
| 175 | + | return fmt.Errorf("bad -override-at/-override-size/-override-type") | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | var ovDeadline time.Time // zero: no timed unmap pending | |||
| 179 | + | unmapOverride := func(msg string) { | |||
| 180 | + | if ovWin == 0 { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | xproto.UnmapWindow(b, ovWin) | |||
| 184 | + | ovWin = 0 | |||
| 185 | + | ovDeadline = time.Time{} | |||
| 186 | + | fmt.Println(msg) | |||
| 187 | + | } | |||
| 188 | + | mapOverride := func() error { | |||
| 189 | + | id, err := c.NewXID() | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | ovWin = xproto.Window(id) | |||
| 194 | + | var ov xproto.CreateWindowValues | |||
| 195 | + | ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true) | |||
| 196 | + | xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov) | |||
| 197 | + | typ := typeMenu | |||
| 198 | + | if *overrideType == "tooltip" { | |||
| 199 | + | typ = typeTooltip | |||
| 200 | + | } | |||
| 201 | + | var tb [4]byte | |||
| 202 | + | binary.LittleEndian.PutUint32(tb[:], typ) | |||
| 203 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:]) | |||
| 204 | + | if *overrideShape { | |||
| 205 | + | shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil) | |||
| 206 | + | } | |||
| 207 | + | xproto.MapWindow(b, ovWin) | |||
| 208 | + | fmt.Println("override mapped") | |||
| 209 | + | if *overrideFor > 0 { | |||
| 210 | + | ovDeadline = time.Now().Add(*overrideFor) | |||
| 211 | + | } | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | var ovErr error | |||
| 215 | + | ||||
| 216 | + | // Fullscreen requests go to the root window as a client message, as ICCCM | |||
| 217 | + | // and EWMH say for a mapped window. | |||
| 218 | + | fsMsg := func(action uint32) { | |||
| 219 | + | var ev [32]byte | |||
| 220 | + | ev[0] = byte(xproto.KindClientMessage) | |||
| 221 | + | ev[1] = 32 | |||
| 222 | + | binary.LittleEndian.PutUint32(ev[4:], uint32(win)) | |||
| 223 | + | binary.LittleEndian.PutUint32(ev[8:], netWMState) | |||
| 224 | + | binary.LittleEndian.PutUint32(ev[12:], action) | |||
| 225 | + | binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen) | |||
| 226 | + | xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev) | |||
| 227 | + | } | |||
| 228 | + | var fsAt, fsOffAt time.Time // zero: nothing pending | |||
| 229 | + | ||||
| 230 | + | mapped, done, isFS := false, false, false | |||
| 231 | + | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | |||
| 232 | + | hd := &xturbo.Handler{ | |||
| 233 | + | OnEvent: func(ev *xturbo.Event) { | |||
| 234 | + | switch ev.Kind { | |||
| 235 | + | case xproto.KindExpose: | |||
| 236 | + | xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}}) | |||
| 237 | + | case xproto.KindConfigureNotify: | |||
| 238 | + | e := xproto.ConfigureNotifyOf(ev) | |||
| 239 | + | if e.Window() != win { | |||
| 240 | + | break | |||
| 241 | + | } | |||
| 242 | + | cw, ch = e.Width(), e.Height() | |||
| 243 | + | if !mapped { | |||
| 244 | + | mapped = true | |||
| 245 | + | fmt.Printf("mapped %dx%d\n", cw, ch) | |||
| 246 | + | if *override { | |||
| 247 | + | ovErr = mapOverride() | |||
| 248 | + | } | |||
| 249 | + | if *fullscreen { | |||
| 250 | + | fsAt = time.Now().Add(*fullscreenDelay) | |||
| 251 | + | } | |||
| 252 | + | if *keyboardGrab { | |||
| 253 | + | xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync) | |||
| 254 | + | fmt.Println("grab") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | case xproto.KindPropertyNotify: | |||
| 258 | + | if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) { | |||
| 259 | + | seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64) | |||
| 260 | + | if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil { | |||
| 261 | + | ovErr = err | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | case xproto.KindFocusIn: | |||
| 265 | + | fmt.Println("focus") | |||
| 266 | + | case xproto.KindFocusOut: | |||
| 267 | + | if *dismiss && xproto.FocusOutOf(ev).Event() == win { | |||
| 268 | + | unmapOverride("dismissed") | |||
| 269 | + | } | |||
| 270 | + | case xproto.KindClientMessage: | |||
| 271 | + | e := xproto.ClientMessageOf(ev) | |||
| 272 | + | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | |||
| 273 | + | done = true | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | }, | |||
| 277 | + | OnReply: func(r *xturbo.Reply) { | |||
| 278 | + | rep, ok := xproto.ParseGetPropertyReply(r.Raw) | |||
| 279 | + | if !ok { | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | on := false | |||
| 283 | + | for v := rep.Value(); len(v) >= 4; v = v[4:] { | |||
| 284 | + | on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen | |||
| 285 | + | } | |||
| 286 | + | // Report the state the window manager published, once per change. | |||
| 287 | + | switch { | |||
| 288 | + | case on && !isFS: | |||
| 289 | + | fmt.Printf("fullscreen %dx%d\n", cw, ch) | |||
| 290 | + | case !on && isFS: | |||
| 291 | + | fmt.Printf("fullscreen off %dx%d\n", cw, ch) | |||
| 292 | + | } | |||
| 293 | + | isFS = on | |||
| 294 | + | }, | |||
| 295 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |||
| 296 | + | } | |||
| 297 | + | for !done { | |||
| 298 | + | events := int16(unix.POLLIN) | |||
| 299 | + | if err := c.Flush(); err == xturbo.ErrWouldBlock { | |||
| 300 | + | events |= unix.POLLOUT | |||
| 301 | + | } else if err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | timeout := -1 | |||
| 305 | + | for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} { | |||
| 306 | + | if !d.IsZero() { | |||
| 307 | + | ms := max(int(time.Until(d).Milliseconds())+1, 0) | |||
| 308 | + | if timeout < 0 || ms < timeout { | |||
| 309 | + | timeout = ms | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | |||
| 314 | + | if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR { | |||
| 315 | + | return err | |||
| 316 | + | } | |||
| 317 | + | if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) { | |||
| 318 | + | unmapOverride("override unmapped") | |||
| 319 | + | } | |||
| 320 | + | if !fsAt.IsZero() && !time.Now().Before(fsAt) { | |||
| 321 | + | fsAt = time.Time{} | |||
| 322 | + | fsMsg(1) | |||
| 323 | + | if *fullscreenOff > 0 { | |||
| 324 | + | fsOffAt = time.Now().Add(*fullscreenOff) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) { | |||
| 328 | + | fsOffAt = time.Time{} | |||
| 329 | + | fsMsg(0) | |||
| 330 | + | } | |||
| 331 | + | if err := c.Dispatch(hd); err != nil { | |||
| 332 | + | return err | |||
| 333 | + | } | |||
| 334 | + | if ovErr != nil { | |||
| 335 | + | return ovErr | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | return nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func parseSize(s string) (w, h int, ok bool) { | |||
| 342 | + | ws, hs, found := strings.Cut(s, "x") | |||
| 343 | + | if !found { | |||
| 344 | + | return 0, 0, false | |||
| 345 | + | } | |||
| 346 | + | w, err1 := strconv.Atoi(ws) | |||
| 347 | + | h, err2 := strconv.Atoi(hs) | |||
| 348 | + | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func atoi(s string) int { | |||
| 352 | + | n, _ := strconv.Atoi(s) | |||
| 353 | + | return n | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // tool is the connection of the windowless modes. Events read while waiting | |||
| 357 | + | // for a reply are queued, so the code reads linearly. | |||
| 358 | + | type tool struct { | |||
| 359 | + | c *xturbo.Conn | |||
| 360 | + | b *xturbo.Buf | |||
| 361 | + | win xproto.Window | |||
| 362 | + | q []xturbo.Event | |||
| 363 | + | reply []byte | |||
| 364 | + | h xturbo.Handler | |||
| 365 | + | atoms map[string]xproto.Atom | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | const chunk = 65536 | |||
| 369 | + | ||||
| 370 | + | func newTool() (*tool, error) { | |||
| 371 | + | c, err := xturbo.Dial("") | |||
| 372 | + | if err != nil { | |||
| 373 | + | return nil, err | |||
| 374 | + | } | |||
| 375 | + | t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}} | |||
| 376 | + | t.h = xturbo.Handler{ | |||
| 377 | + | OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) }, | |||
| 378 | + | OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) }, | |||
| 379 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |||
| 380 | + | } | |||
| 381 | + | names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL", | |||
| 382 | + | "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"} | |||
| 383 | + | ids := make([]uint32, len(names)) | |||
| 384 | + | if err := c.InternAtoms(names, false, ids); err != nil { | |||
| 385 | + | return nil, err | |||
| 386 | + | } | |||
| 387 | + | for i, n := range names { | |||
| 388 | + | t.atoms[n] = xproto.Atom(ids[i]) | |||
| 389 | + | } | |||
| 390 | + | xid, err := c.NewXID() | |||
| 391 | + | if err != nil { | |||
| 392 | + | return nil, err | |||
| 393 | + | } | |||
| 394 | + | t.win = xproto.Window(xid) | |||
| 395 | + | var v xproto.CreateWindowValues | |||
| 396 | + | v.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 397 | + | xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v) | |||
| 398 | + | return t, t.c.Sync(&t.h) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // rpc sends the queued requests and returns the reply of seq. | |||
| 402 | + | func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) { | |||
| 403 | + | t.reply = t.reply[:0] | |||
| 404 | + | if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil { | |||
| 405 | + | return nil, err | |||
| 406 | + | } | |||
| 407 | + | if err := t.c.Sync(&t.h); err != nil { | |||
| 408 | + | return nil, err | |||
| 409 | + | } | |||
| 410 | + | return t.reply, nil | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // next returns the next event, or false at the deadline. | |||
| 414 | + | func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) { | |||
| 415 | + | for len(t.q) == 0 { | |||
| 416 | + | events := int16(unix.POLLIN) | |||
| 417 | + | if err := t.c.Flush(); err == xturbo.ErrWouldBlock { | |||
| 418 | + | events |= unix.POLLOUT | |||
| 419 | + | } else if err != nil { | |||
| 420 | + | return xturbo.Event{}, false, err | |||
| 421 | + | } | |||
| 422 | + | ms := int(time.Until(deadline).Milliseconds()) + 1 | |||
| 423 | + | if ms <= 0 { | |||
| 424 | + | return xturbo.Event{}, false, nil | |||
| 425 | + | } | |||
| 426 | + | fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}} | |||
| 427 | + | if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR { | |||
| 428 | + | return xturbo.Event{}, false, err | |||
| 429 | + | } | |||
| 430 | + | if err := t.c.Dispatch(&t.h); err != nil { | |||
| 431 | + | return xturbo.Event{}, false, err | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ev := t.q[0] | |||
| 435 | + | t.q = t.q[1:] | |||
| 436 | + | return ev, true, nil | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func (t *tool) selAtom(name string) (xproto.Atom, error) { | |||
| 440 | + | switch name { | |||
| 441 | + | case "CLIPBOARD", "PRIMARY": | |||
| 442 | + | return t.atoms[name], nil | |||
| 443 | + | } | |||
| 444 | + | return 0, fmt.Errorf("bad selection %q", name) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error { | |||
| 448 | + | t, err := newTool() | |||
| 449 | + | if err != nil { | |||
| 450 | + | return err | |||
| 451 | + | } | |||
| 452 | + | defer t.c.Close() | |||
| 453 | + | switch { | |||
| 454 | + | case copyText != "": | |||
| 455 | + | return t.copyMode(copyText, copySel, copyFor) | |||
| 456 | + | case paste != "": | |||
| 457 | + | return t.pasteMode(paste) | |||
| 458 | + | case xsettings: | |||
| 459 | + | return t.xsettingsMode() | |||
| 460 | + | } | |||
| 461 | + | return t.cursorMode() | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func (t *tool) copyMode(text, selName string, dur time.Duration) error { | |||
| 465 | + | data := []byte(text) | |||
| 466 | + | if f, ok := strings.CutPrefix(text, "@"); ok { | |||
| 467 | + | var err error | |||
| 468 | + | if data, err = os.ReadFile(f); err != nil { | |||
| 469 | + | return err | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | sel, err := t.selAtom(selName) | |||
| 473 | + | if err != nil { | |||
| 474 | + | return err | |||
| 475 | + | } | |||
| 476 | + | xproto.SetSelectionOwner(t.b, t.win, sel, 0) | |||
| 477 | + | raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel)) | |||
| 478 | + | if err != nil { | |||
| 479 | + | return err | |||
| 480 | + | } | |||
| 481 | + | if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win { | |||
| 482 | + | return fmt.Errorf("selection %s not owned", selName) | |||
| 483 | + | } | |||
| 484 | + | fmt.Println("copy owned") | |||
| 485 | + | var ( | |||
| 486 | + | incrReq xproto.Window // requestor of the running INCR transfer | |||
| 487 | + | incrProp, incrTgt xproto.Atom | |||
| 488 | + | incrOff int | |||
| 489 | + | incrLive bool | |||
| 490 | + | notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) { | |||
| 491 | + | var ev [32]byte | |||
| 492 | + | d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop} | |||
| 493 | + | d.Put(&ev) | |||
| 494 | + | xproto.SendEvent(t.b, false, req, 0, &ev) | |||
| 495 | + | } | |||
| 496 | + | ) | |||
| 497 | + | deadline := time.Now().Add(dur) | |||
| 498 | + | for { | |||
| 499 | + | ev, ok, err := t.next(deadline) | |||
| 500 | + | if err != nil || !ok { | |||
| 501 | + | return err | |||
| 502 | + | } | |||
| 503 | + | switch ev.Kind { | |||
| 504 | + | case xproto.KindSelectionClear: | |||
| 505 | + | fmt.Println("copy lost") | |||
| 506 | + | return nil | |||
| 507 | + | case xproto.KindSelectionRequest: | |||
| 508 | + | e := xproto.SelectionRequestOf(&ev) | |||
| 509 | + | req, prop, tgt := e.Requestor(), e.Property(), e.Target() | |||
| 510 | + | if prop == 0 { | |||
| 511 | + | prop = tgt | |||
| 512 | + | } | |||
| 513 | + | switch tgt { | |||
| 514 | + | case t.atoms["TARGETS"]: | |||
| 515 | + | var l [16]byte | |||
| 516 | + | for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} { | |||
| 517 | + | binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n])) | |||
| 518 | + | } | |||
| 519 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:]) | |||
| 520 | + | case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]: | |||
| 521 | + | if len(data) <= chunk { | |||
| 522 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data) | |||
| 523 | + | break | |||
| 524 | + | } | |||
| 525 | + | var cv xproto.ChangeWindowAttributesValues | |||
| 526 | + | cv.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 527 | + | xproto.ChangeWindowAttributes(t.b, req, &cv) | |||
| 528 | + | var n [4]byte | |||
| 529 | + | binary.LittleEndian.PutUint32(n[:], uint32(len(data))) | |||
| 530 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:]) | |||
| 531 | + | incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true | |||
| 532 | + | default: | |||
| 533 | + | prop = 0 | |||
| 534 | + | } | |||
| 535 | + | notify(req, e.Selection(), tgt, prop, e.Time()) | |||
| 536 | + | case xproto.KindPropertyNotify: | |||
| 537 | + | e := xproto.PropertyNotifyOf(&ev) | |||
| 538 | + | if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete { | |||
| 539 | + | break | |||
| 540 | + | } | |||
| 541 | + | end := min(incrOff+chunk, len(data)) | |||
| 542 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end]) | |||
| 543 | + | if end == incrOff { | |||
| 544 | + | incrLive = false | |||
| 545 | + | } | |||
| 546 | + | incrOff = end | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func (t *tool) pasteMode(selName string) error { | |||
| 552 | + | sel, err := t.selAtom(selName) | |||
| 553 | + | if err != nil { | |||
| 554 | + | return err | |||
| 555 | + | } | |||
| 556 | + | prop := t.atoms["_PROBE_SEL"] | |||
| 557 | + | xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0) | |||
| 558 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 559 | + | var got []byte | |||
| 560 | + | incr := false | |||
| 561 | + | get := func() (value []byte, typ xproto.Atom, err error) { | |||
| 562 | + | raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22)) | |||
| 563 | + | if err != nil { | |||
| 564 | + | return nil, 0, err | |||
| 565 | + | } | |||
| 566 | + | r, ok := xproto.ParseGetPropertyReply(raw) | |||
| 567 | + | if !ok { | |||
| 568 | + | return nil, 0, fmt.Errorf("bad GetProperty reply") | |||
| 569 | + | } | |||
| 570 | + | return append([]byte(nil), r.Value()...), r.Type(), nil | |||
| 571 | + | } | |||
| 572 | + | for { | |||
| 573 | + | ev, ok, err := t.next(deadline) | |||
| 574 | + | if err != nil { | |||
| 575 | + | return err | |||
| 576 | + | } | |||
| 577 | + | if !ok { | |||
| 578 | + | return fmt.Errorf("paste timed out") | |||
| 579 | + | } | |||
| 580 | + | switch ev.Kind { | |||
| 581 | + | case xproto.KindSelectionNotify: | |||
| 582 | + | if xproto.SelectionNotifyOf(&ev).Property() == 0 { | |||
| 583 | + | fmt.Println("paste none") | |||
| 584 | + | return nil | |||
| 585 | + | } | |||
| 586 | + | v, typ, err := get() | |||
| 587 | + | if err != nil { | |||
| 588 | + | return err | |||
| 589 | + | } | |||
| 590 | + | if typ != t.atoms["INCR"] { | |||
| 591 | + | return printPaste(v) | |||
| 592 | + | } | |||
| 593 | + | incr = true // reading and deleting the INCR property starts the transfer | |||
| 594 | + | case xproto.KindPropertyNotify: | |||
| 595 | + | e := xproto.PropertyNotifyOf(&ev) | |||
| 596 | + | if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue { | |||
| 597 | + | break | |||
| 598 | + | } | |||
| 599 | + | v, _, err := get() | |||
| 600 | + | if err != nil { | |||
| 601 | + | return err | |||
| 602 | + | } | |||
| 603 | + | if len(v) == 0 { | |||
| 604 | + | return printPaste(got) | |||
| 605 | + | } | |||
| 606 | + | got = append(got, v...) | |||
| 607 | + | } | |||
| 608 | + | deadline = time.Now().Add(10 * time.Second) | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | func printPaste(v []byte) error { | |||
| 613 | + | if len(v) <= 4096 { | |||
| 614 | + | fmt.Printf("paste %q\n", v) | |||
| 615 | + | } else { | |||
| 616 | + | fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v)) | |||
| 617 | + | } | |||
| 618 | + | return nil | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func (t *tool) xsettingsMode() error { | |||
| 622 | + | raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"])) | |||
| 623 | + | if err != nil { | |||
| 624 | + | return err | |||
| 625 | + | } | |||
| 626 | + | r, ok := xproto.ParseGetSelectionOwnerReply(raw) | |||
| 627 | + | if !ok || r.Owner() == 0 { | |||
| 628 | + | fmt.Println("xsettings none") | |||
| 629 | + | return nil | |||
| 630 | + | } | |||
| 631 | + | raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16)) | |||
| 632 | + | if err != nil { | |||
| 633 | + | return err | |||
| 634 | + | } | |||
| 635 | + | pr, ok := xproto.ParseGetPropertyReply(raw) | |||
| 636 | + | if !ok || len(pr.Value()) < 12 { | |||
| 637 | + | fmt.Println("xsettings none") | |||
| 638 | + | return nil | |||
| 639 | + | } | |||
| 640 | + | v := pr.Value() | |||
| 641 | + | if v[0] != 0 { | |||
| 642 | + | return fmt.Errorf("xsettings: big-endian data") | |||
| 643 | + | } | |||
| 644 | + | le := binary.LittleEndian | |||
| 645 | + | serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:])) | |||
| 646 | + | vals := map[string]string{} | |||
| 647 | + | p := v[12:] | |||
| 648 | + | pad := func(n int) int { return (n + 3) &^ 3 } | |||
| 649 | + | for i := 0; i < n; i++ { | |||
| 650 | + | if len(p) < 4 { | |||
| 651 | + | return fmt.Errorf("xsettings: truncated") | |||
| 652 | + | } | |||
| 653 | + | typ, nl := p[0], int(le.Uint16(p[2:])) | |||
| 654 | + | if len(p) < 4+pad(nl)+4 { | |||
| 655 | + | return fmt.Errorf("xsettings: truncated") | |||
| 656 | + | } | |||
| 657 | + | name := string(p[4 : 4+nl]) | |||
| 658 | + | p = p[4+pad(nl)+4:] // name, last-change-serial | |||
| 659 | + | switch typ { | |||
| 660 | + | case 0: | |||
| 661 | + | vals[name] = strconv.Itoa(int(int32(le.Uint32(p)))) | |||
| 662 | + | p = p[4:] | |||
| 663 | + | case 1: | |||
| 664 | + | sl := int(le.Uint32(p)) | |||
| 665 | + | vals[name] = string(p[4 : 4+sl]) | |||
| 666 | + | p = p[4+pad(sl):] | |||
| 667 | + | case 2: | |||
| 668 | + | p = p[8:] | |||
| 669 | + | default: | |||
| 670 | + | return fmt.Errorf("xsettings: type %d", typ) | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gdk/WindowScalingFactor=%s Gdk/UnscaledDPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", | |||
| 674 | + | serial, vals["Xft/DPI"], vals["Gdk/WindowScalingFactor"], vals["Gdk/UnscaledDPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) | |||
| 675 | + | return nil | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | func (t *tool) cursorMode() error { | |||
| 679 | + | if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil { | |||
| 680 | + | return err | |||
| 681 | + | } | |||
| 682 | + | raw, err := t.rpc(xfixes.GetCursorImage(t.b)) | |||
| 683 | + | if err != nil { | |||
| 684 | + | return err | |||
| 685 | + | } | |||
| 686 | + | r, ok := xfixes.ParseGetCursorImageReply(raw) | |||
| 687 | + | if !ok { | |||
| 688 | + | return fmt.Errorf("bad GetCursorImage reply") | |||
| 689 | + | } | |||
| 690 | + | fmt.Printf("cursor %dx%d\n", r.Width(), r.Height()) | |||
| 691 | + | return nil | |||
| 692 | + | } | |||
A internal/app/xwayland_test.go +808 -0
| @@ -0,0 +1,808 @@ | |||||
| 1 | + | //go:build xwayland | |||
| 2 | + | ||||
| 3 | + | // End-to-end test of the built-in XWM: a real Xwayland and a real X11 client | |||
| 4 | + | // (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on | |||
| 5 | + | // PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with | |||
| 6 | + | // `make e2e-xwayland`. | |||
| 7 | + | package app | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "crypto/sha256" | |||
| 12 | + | "fmt" | |||
| 13 | + | "image" | |||
| 14 | + | "image/png" | |||
| 15 | + | "io" | |||
| 16 | + | "os" | |||
| 17 | + | "os/exec" | |||
| 18 | + | "path/filepath" | |||
| 19 | + | "regexp" | |||
| 20 | + | "strconv" | |||
| 21 | + | "strings" | |||
| 22 | + | "sync" | |||
| 23 | + | "testing" | |||
| 24 | + | "time" | |||
| 25 | + | ||||
| 26 | + | "github.com/bnema/zerowrap" | |||
| 27 | + | ||||
| 28 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 29 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |||
| 30 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | xwaylandTimeout = 10 * time.Second | |||
| 35 | + | xwaylandW, xwaylandH = 640, 360 | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`) | |||
| 39 | + | ||||
| 40 | + | // probe is a running x11probe. | |||
| 41 | + | type probe struct { | |||
| 42 | + | cmd *exec.Cmd | |||
| 43 | + | out *lockedBuffer | |||
| 44 | + | waited chan error | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // xwaylandEnv is a running headless compositor with the built-in XWM. | |||
| 48 | + | type xwaylandEnv struct { | |||
| 49 | + | bin, display, statePath, shots string | |||
| 50 | + | script *io.PipeWriter | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // startXwayland runs the compositor on one headless output and waits for its | |||
| 54 | + | // X11 display. It skips the test without Xwayland and stops everything when | |||
| 55 | + | // the test ends. | |||
| 56 | + | func startXwayland(t *testing.T) *xwaylandEnv { | |||
| 57 | + | t.Helper() | |||
| 58 | + | return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // startXwaylandWith is startXwayland with the given headless outputs, left to | |||
| 62 | + | // right, and an optional change to the default config. | |||
| 63 | + | func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv { | |||
| 64 | + | t.Helper() | |||
| 65 | + | if _, err := exec.LookPath("Xwayland"); err != nil { | |||
| 66 | + | t.Skip("Xwayland not on PATH") | |||
| 67 | + | } | |||
| 68 | + | // The compositor must not read or write the live session's state. | |||
| 69 | + | for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} { | |||
| 70 | + | if v, ok := os.LookupEnv(k); ok { | |||
| 71 | + | os.Unsetenv(k) | |||
| 72 | + | t.Cleanup(func() { os.Setenv(k, v) }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | runtime := t.TempDir() | |||
| 76 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 77 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 78 | + | shots := t.TempDir() | |||
| 79 | + | bin := buildProbe(t) | |||
| 80 | + | ||||
| 81 | + | script, scriptW := io.Pipe() | |||
| 82 | + | logs := &lockedBuffer{} | |||
| 83 | + | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs}))) | |||
| 84 | + | cfg := config.Defaults() | |||
| 85 | + | cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true} | |||
| 86 | + | if mutate != nil { | |||
| 87 | + | mutate(&cfg) | |||
| 88 | + | } | |||
| 89 | + | done := make(chan error, 1) | |||
| 90 | + | go func() { | |||
| 91 | + | done <- Run(ctx, Options{ | |||
| 92 | + | Backend: "headless", NoTerminal: true, Config: cfg, Script: script, | |||
| 93 | + | ScreenshotDir: shots, Sizes: sizes, | |||
| 94 | + | }) | |||
| 95 | + | }() | |||
| 96 | + | t.Cleanup(func() { | |||
| 97 | + | if t.Failed() { | |||
| 98 | + | t.Logf("compositor log:\n%s", logs.String()) | |||
| 99 | + | } | |||
| 100 | + | cancel() | |||
| 101 | + | _ = scriptW.Close() | |||
| 102 | + | select { | |||
| 103 | + | case err := <-done: | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Errorf("Run: %v", err) | |||
| 106 | + | } | |||
| 107 | + | case <-time.After(5 * time.Second): | |||
| 108 | + | t.Error("Run did not stop") | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | ||||
| 112 | + | var display string | |||
| 113 | + | until(t, "X11 display in the log", func() bool { | |||
| 114 | + | if m := displayRe.FindStringSubmatch(logs.String()); m != nil { | |||
| 115 | + | display = m[1] | |||
| 116 | + | return true | |||
| 117 | + | } | |||
| 118 | + | return false | |||
| 119 | + | }) | |||
| 120 | + | statePath, err := statefile.Path(runtime, "wayland-1") | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW} | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | |||
| 128 | + | // up in the state with its class and pid, is drawn, tiles next to a second | |||
| 129 | + | // one, receives focus and is closed by the close-window bind. | |||
| 130 | + | func TestXwaylandProbe(t *testing.T) { | |||
| 131 | + | e := startXwayland(t) | |||
| 132 | + | bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script | |||
| 133 | + | first := startProbe(t, bin, display, "-title", "one") | |||
| 134 | + | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | |||
| 135 | + | ||||
| 136 | + | var st statefile.State | |||
| 137 | + | until(t, "one visible X11Probe window", func() bool { | |||
| 138 | + | st = readState(statePath) | |||
| 139 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible | |||
| 140 | + | }) | |||
| 141 | + | if w := st.Windows[0]; w.PID != first.cmd.Process.Pid { | |||
| 142 | + | t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // The client draws green and the compositor shows it. | |||
| 146 | + | id := st.Windows[0].ID | |||
| 147 | + | until(t, "green window in latest.png", func() bool { | |||
| 148 | + | r, ok := tileRect(readState(statePath), id) | |||
| 149 | + | return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5 | |||
| 150 | + | }) | |||
| 151 | + | ||||
| 152 | + | // Focus round trip: the compositor focused the window, the client saw it. | |||
| 153 | + | until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") }) | |||
| 154 | + | ||||
| 155 | + | // A second probe tiles beside the first. | |||
| 156 | + | second := startProbe(t, bin, display, "-title", "two") | |||
| 157 | + | until(t, "two tiled windows", func() bool { | |||
| 158 | + | st = readState(statePath) | |||
| 159 | + | if len(st.Windows) != 2 { | |||
| 160 | + | return false | |||
| 161 | + | } | |||
| 162 | + | for _, w := range st.Windows { | |||
| 163 | + | if w.AppID != "X11Probe" || !w.Visible || w.Floating { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return true | |||
| 168 | + | }) | |||
| 169 | + | until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") }) | |||
| 170 | + | ||||
| 171 | + | // The close-window bind asks the focused (second) probe to close. | |||
| 172 | + | if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | select { | |||
| 176 | + | case err := <-second.waited: | |||
| 177 | + | if err != nil { | |||
| 178 | + | t.Errorf("probe two exited with %v, want 0", err) | |||
| 179 | + | } | |||
| 180 | + | case <-time.After(2 * time.Second): | |||
| 181 | + | t.Fatal("probe two did not exit within 2s of Super+q") | |||
| 182 | + | } | |||
| 183 | + | until(t, "one window left", func() bool { | |||
| 184 | + | st = readState(statePath) | |||
| 185 | + | return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid | |||
| 186 | + | }) | |||
| 187 | + | select { | |||
| 188 | + | case err := <-first.waited: | |||
| 189 | + | t.Fatalf("probe one exited (%v) though only the focused probe was closed", err) | |||
| 190 | + | default: | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // buildProbe builds testdata/x11probe. | |||
| 195 | + | func buildProbe(t *testing.T) string { | |||
| 196 | + | t.Helper() | |||
| 197 | + | bin := filepath.Join(t.TempDir(), "x11probe") | |||
| 198 | + | build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe") | |||
| 199 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 200 | + | if out, err := build.CombinedOutput(); err != nil { | |||
| 201 | + | t.Fatalf("build x11probe: %v: %s", err, out) | |||
| 202 | + | } | |||
| 203 | + | return bin | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // startProbe starts x11probe on display, with X11 as its only display | |||
| 207 | + | // server, and stops it when the test ends. | |||
| 208 | + | func startProbe(t *testing.T, bin, display string, args ...string) *probe { | |||
| 209 | + | t.Helper() | |||
| 210 | + | p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)} | |||
| 211 | + | p.cmd = exec.Command(bin, args...) | |||
| 212 | + | p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display) | |||
| 213 | + | p.cmd.Stdout = p.out | |||
| 214 | + | stderr := &lockedBuffer{} | |||
| 215 | + | p.cmd.Stderr = stderr | |||
| 216 | + | if err := p.cmd.Start(); err != nil { | |||
| 217 | + | t.Fatal(err) | |||
| 218 | + | } | |||
| 219 | + | var exited sync.Once | |||
| 220 | + | go func() { p.waited <- p.cmd.Wait() }() | |||
| 221 | + | t.Cleanup(func() { | |||
| 222 | + | exited.Do(func() { | |||
| 223 | + | _ = p.cmd.Process.Kill() | |||
| 224 | + | select { | |||
| 225 | + | case <-p.waited: | |||
| 226 | + | case <-time.After(2 * time.Second): | |||
| 227 | + | } | |||
| 228 | + | }) | |||
| 229 | + | if s := stderr.String(); s != "" { | |||
| 230 | + | t.Logf("x11probe stderr: %s", s) | |||
| 231 | + | } | |||
| 232 | + | }) | |||
| 233 | + | return p | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func envWithout(keys ...string) []string { | |||
| 237 | + | var env []string | |||
| 238 | + | next: | |||
| 239 | + | for _, kv := range os.Environ() { | |||
| 240 | + | for _, k := range keys { | |||
| 241 | + | if strings.HasPrefix(kv, k+"=") { | |||
| 242 | + | continue next | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | env = append(env, kv) | |||
| 246 | + | } | |||
| 247 | + | return env | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // until polls cond every 20 ms and fails the test after xwaylandTimeout. | |||
| 251 | + | func until(t *testing.T, what string, cond func() bool) { | |||
| 252 | + | t.Helper() | |||
| 253 | + | for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) { | |||
| 254 | + | if time.Now().After(deadline) { | |||
| 255 | + | t.Fatalf("timeout waiting for %s", what) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // readState returns the current state file, or an empty state while it does | |||
| 261 | + | // not exist or is being replaced. | |||
| 262 | + | func readState(path string) statefile.State { | |||
| 263 | + | st, err := statefile.Read(path) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return statefile.State{} | |||
| 266 | + | } | |||
| 267 | + | return st | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // tileRect finds the rectangle of window id in the tiled columns. | |||
| 271 | + | func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) { | |||
| 272 | + | for _, ws := range st.Workspaces { | |||
| 273 | + | for _, c := range ws.Columns { | |||
| 274 | + | for _, w := range c.Windows { | |||
| 275 | + | if w.ID == id { | |||
| 276 | + | return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return image.Rectangle{}, false | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // greenShare is the fraction of pixels of r in the PNG at path that are | |||
| 285 | + | // clearly green; 0 when the image cannot be read yet. | |||
| 286 | + | func greenShare(path string, r image.Rectangle) float64 { | |||
| 287 | + | f, err := os.Open(path) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return 0 | |||
| 290 | + | } | |||
| 291 | + | defer f.Close() | |||
| 292 | + | img, err := png.Decode(f) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return 0 | |||
| 295 | + | } | |||
| 296 | + | r = r.Intersect(img.Bounds()) | |||
| 297 | + | if r.Empty() { | |||
| 298 | + | return 0 | |||
| 299 | + | } | |||
| 300 | + | n := 0 | |||
| 301 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 302 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 303 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 304 | + | if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 { | |||
| 305 | + | n++ | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // magentaShare is the fraction of pixels of r in the PNG at path that are | |||
| 313 | + | // clearly magenta; 0 when the image cannot be read yet. | |||
| 314 | + | func magentaShare(path string, r image.Rectangle) float64 { | |||
| 315 | + | f, err := os.Open(path) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return 0 | |||
| 318 | + | } | |||
| 319 | + | defer f.Close() | |||
| 320 | + | img, err := png.Decode(f) | |||
| 321 | + | if err != nil { | |||
| 322 | + | return 0 | |||
| 323 | + | } | |||
| 324 | + | r = r.Intersect(img.Bounds()) | |||
| 325 | + | if r.Empty() { | |||
| 326 | + | return 0 | |||
| 327 | + | } | |||
| 328 | + | n := 0 | |||
| 329 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 330 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 331 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 332 | + | if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 { | |||
| 333 | + | n++ | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // TestXwaylandOverride: an override-redirect window (a menu) of an X11 client | |||
| 341 | + | // is drawn above the toplevel at its X position, is not a window in the | |||
| 342 | + | // state, leaves keyboard focus on the toplevel and vanishes when unmapped. | |||
| 343 | + | func TestXwaylandOverride(t *testing.T) { | |||
| 344 | + | e := startXwayland(t) | |||
| 345 | + | // Probe unmaps the menu 3 s after mapping it; the first checks run well | |||
| 346 | + | // inside that window. | |||
| 347 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50", | |||
| 348 | + | "-override-size", "120x80", "-override-for", "3s") | |||
| 349 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 350 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 351 | + | box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin | |||
| 352 | + | until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 }) | |||
| 353 | + | ||||
| 354 | + | var st statefile.State | |||
| 355 | + | until(t, "focused X11Probe toplevel", func() bool { | |||
| 356 | + | st = readState(e.statePath) | |||
| 357 | + | return len(st.Windows) >= 1 && st.Window != nil | |||
| 358 | + | }) | |||
| 359 | + | if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" { | |||
| 360 | + | t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows) | |||
| 361 | + | } | |||
| 362 | + | id := st.Windows[0].ID | |||
| 363 | + | if st.Window.ID != id { | |||
| 364 | + | t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id) | |||
| 365 | + | } | |||
| 366 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 367 | + | ||||
| 368 | + | // The menu is above the toplevel: its box is magenta, not green. | |||
| 369 | + | if g := greenShare(shot, box); g > 0.1 { | |||
| 370 | + | t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g) | |||
| 371 | + | } | |||
| 372 | + | until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") }) | |||
| 373 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 374 | + | return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9 | |||
| 375 | + | }) | |||
| 376 | + | if st := readState(e.statePath); st.Window == nil || st.Window.ID != id { | |||
| 377 | + | t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel | |||
| 382 | + | // when core focuses another window, which is how toolkits close their menus. | |||
| 383 | + | func TestXwaylandOverrideDismiss(t *testing.T) { | |||
| 384 | + | e := startXwayland(t) | |||
| 385 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout") | |||
| 386 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 387 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 388 | + | // A second X11 window takes the focus away from the menu owner. | |||
| 389 | + | startProbe(t, e.bin, e.display, "-title", "other") | |||
| 390 | + | until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") }) | |||
| 391 | + | box := image.Rect(100, 50, 220, 130) | |||
| 392 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 393 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0 | |||
| 394 | + | }) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | // TestXwaylandOverrideShape: a menu with an empty input shape is still drawn | |||
| 398 | + | // and still not a window. | |||
| 399 | + | func TestXwaylandOverrideShape(t *testing.T) { | |||
| 400 | + | e := startXwayland(t) | |||
| 401 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip") | |||
| 402 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 403 | + | until(t, "magenta tooltip in latest.png", func() bool { | |||
| 404 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9 | |||
| 405 | + | }) | |||
| 406 | + | until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 }) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // fullOutput is the share of the output screenshot at path that is green. | |||
| 410 | + | func fullOutput(path string) float64 { | |||
| 411 | + | return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH)) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // windowOf returns the state window with the given pid. | |||
| 415 | + | func windowOf(st statefile.State, pid int) (statefile.Window, bool) { | |||
| 416 | + | for _, w := range st.Windows { | |||
| 417 | + | if w.PID == pid { | |||
| 418 | + | return w, true | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | return statefile.Window{}, false | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | // TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the | |||
| 425 | + | // output, the toggle bind leaves it, and a request made right at the map is | |||
| 426 | + | // ignored (the grace, current policy). | |||
| 427 | + | func TestXwaylandFullscreen(t *testing.T) { | |||
| 428 | + | // Gaps make a tile visibly smaller than the output. | |||
| 429 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |||
| 430 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 431 | + | corner := image.Rect(0, 0, 4, 4) | |||
| 432 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s") | |||
| 433 | + | until(t, "tiled with a gap", func() bool { | |||
| 434 | + | return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0 | |||
| 435 | + | }) | |||
| 436 | + | until(t, "probe fullscreen at output size", func() bool { | |||
| 437 | + | return strings.Contains(p.out.String(), "fullscreen 640x360") | |||
| 438 | + | }) | |||
| 439 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |||
| 440 | + | ||||
| 441 | + | // The toggle bind (cmd+shift+f) leaves fullscreen. | |||
| 442 | + | if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil { | |||
| 443 | + | t.Fatal(err) | |||
| 444 | + | } | |||
| 445 | + | until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 446 | + | until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 }) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // TestXwaylandFullscreenOff: the client leaves fullscreen itself. | |||
| 450 | + | func TestXwaylandFullscreenOff(t *testing.T) { | |||
| 451 | + | e := startXwayland(t) | |||
| 452 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s") | |||
| 453 | + | until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 454 | + | until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 455 | + | until(t, "tiled", func() bool { | |||
| 456 | + | st := readState(e.statePath) | |||
| 457 | + | return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }() | |||
| 458 | + | }) | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | // TestXwaylandInitialFullscreen: a fixed-size window that maps with | |||
| 462 | + | // _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a | |||
| 463 | + | // column of its own, focused, beside the tiled window already open. This is | |||
| 464 | + | // the state it maps with, not a request: the grace does not apply. | |||
| 465 | + | func TestXwaylandInitialFullscreen(t *testing.T) { | |||
| 466 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |||
| 467 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 468 | + | first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100") | |||
| 469 | + | until(t, "first tiled", func() bool { | |||
| 470 | + | return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1 | |||
| 471 | + | }) | |||
| 472 | + | p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360") | |||
| 473 | + | until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 474 | + | until(t, "game in its own column, fullscreen", func() bool { | |||
| 475 | + | st := readState(e.statePath) | |||
| 476 | + | for _, w := range st.Windows { | |||
| 477 | + | if w.AppID == "Game" { | |||
| 478 | + | return !w.Floating && w.Column == 2 && w.Row == 1 | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | return false | |||
| 482 | + | }) | |||
| 483 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |||
| 484 | + | until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // TestXwaylandFullscreenGrace: a request made at once after the map (not the | |||
| 488 | + | // state it maps with) stays tiled (fullscreenGrace in core). | |||
| 489 | + | func TestXwaylandFullscreenGrace(t *testing.T) { | |||
| 490 | + | e := startXwayland(t) | |||
| 491 | + | p := startProbe(t, e.bin, e.display, "-fullscreen") | |||
| 492 | + | until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 493 | + | until(t, "tiled window", func() bool { | |||
| 494 | + | st := readState(e.statePath) | |||
| 495 | + | return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }() | |||
| 496 | + | }) | |||
| 497 | + | // Stay inside the grace, then check it is still not fullscreen. | |||
| 498 | + | time.Sleep(500 * time.Millisecond) | |||
| 499 | + | st := readState(e.statePath) | |||
| 500 | + | if _, ok := tileRect(st, st.Windows[0].ID); !ok { | |||
| 501 | + | t.Error("window went fullscreen inside the grace") | |||
| 502 | + | } | |||
| 503 | + | if strings.Contains(p.out.String(), "fullscreen ") { | |||
| 504 | + | t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String()) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | // TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the | |||
| 509 | + | // keyboard grab the close bind does not fire; after it leaves fullscreen the | |||
| 510 | + | // binds work again. | |||
| 511 | + | func TestXwaylandKeyboardGrab(t *testing.T) { | |||
| 512 | + | e := startXwayland(t) | |||
| 513 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab") | |||
| 514 | + | until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 515 | + | until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") }) | |||
| 516 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 517 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 518 | + | t.Fatal(err) | |||
| 519 | + | } | |||
| 520 | + | select { | |||
| 521 | + | case err := <-p.waited: | |||
| 522 | + | t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err) | |||
| 523 | + | case <-time.After(2 * time.Second): | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | // The probe leaves fullscreen itself; its grab stays, but the binds work again. | |||
| 527 | + | until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 528 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 529 | + | t.Fatal(err) | |||
| 530 | + | } | |||
| 531 | + | select { | |||
| 532 | + | case err := <-p.waited: | |||
| 533 | + | if err != nil { | |||
| 534 | + | t.Errorf("probe exited with %v, want 0", err) | |||
| 535 | + | } | |||
| 536 | + | case <-time.After(2 * time.Second): | |||
| 537 | + | t.Fatal("Super+q did not close the client after it left fullscreen") | |||
| 538 | + | } | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | // TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock | |||
| 542 | + | // the binds. | |||
| 543 | + | func TestXwaylandKeyboardGrabWindowed(t *testing.T) { | |||
| 544 | + | e := startXwayland(t) | |||
| 545 | + | p := startProbe(t, e.bin, e.display, "-keyboard-grab") | |||
| 546 | + | until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") }) | |||
| 547 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 548 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 549 | + | t.Fatal(err) | |||
| 550 | + | } | |||
| 551 | + | select { | |||
| 552 | + | case err := <-p.waited: | |||
| 553 | + | if err != nil { | |||
| 554 | + | t.Errorf("probe exited with %v, want 0", err) | |||
| 555 | + | } | |||
| 556 | + | case <-time.After(2 * time.Second): | |||
| 557 | + | t.Fatal("Super+q did not close a windowed client that holds a keyboard grab") | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | // TestXwaylandOutputHint: with two outputs a window created at the origin of | |||
| 562 | + | // the second one opens on it, and a fullscreen request fills that output. | |||
| 563 | + | func TestXwaylandOutputHint(t *testing.T) { | |||
| 564 | + | sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}} | |||
| 565 | + | e := startXwaylandWith(t, sizes, nil) | |||
| 566 | + | p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms") | |||
| 567 | + | var w statefile.Window | |||
| 568 | + | until(t, "window on HEADLESS-2", func() bool { | |||
| 569 | + | var ok bool | |||
| 570 | + | w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid) | |||
| 571 | + | if ok && w.Output != "HEADLESS-2" { | |||
| 572 | + | t.Logf("window output = %s", w.Output) | |||
| 573 | + | } | |||
| 574 | + | return ok && w.Output == "HEADLESS-2" | |||
| 575 | + | }) | |||
| 576 | + | until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 577 | + | until(t, "HEADLESS-2 all green", func() bool { | |||
| 578 | + | return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99 | |||
| 579 | + | }) | |||
| 580 | + | if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 { | |||
| 581 | + | t.Errorf("HEADLESS-1 green share = %.2f, want empty", g) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window | |||
| 586 | + | // the way it matches a Wayland app ID (steam_app_<id> from Proton). | |||
| 587 | + | func TestXwaylandClassRule(t *testing.T) { | |||
| 588 | + | yes := true | |||
| 589 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | |||
| 590 | + | c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes}) | |||
| 591 | + | }) | |||
| 592 | + | startProbe(t, e.bin, e.display, "-class", "steam_app_413150") | |||
| 593 | + | until(t, "floating steam_app window", func() bool { | |||
| 594 | + | st := readState(e.statePath) | |||
| 595 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating | |||
| 596 | + | }) | |||
| 597 | + | startProbe(t, e.bin, e.display, "-class", "Other") | |||
| 598 | + | until(t, "other class tiled", func() bool { | |||
| 599 | + | for _, w := range readState(e.statePath).Windows { | |||
| 600 | + | if w.AppID == "Other" { | |||
| 601 | + | return !w.Floating | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | return false | |||
| 605 | + | }) | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // waylandEnv is the environment of a Wayland client of the headless | |||
| 609 | + | // compositor: its socket, not the user's session. | |||
| 610 | + | func waylandEnv() []string { | |||
| 611 | + | return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1") | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | // runProbe runs a one-shot x11probe mode and returns its stdout. | |||
| 615 | + | func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string { | |||
| 616 | + | t.Helper() | |||
| 617 | + | ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout) | |||
| 618 | + | defer cancel() | |||
| 619 | + | cmd := exec.CommandContext(ctx, e.bin, args...) | |||
| 620 | + | cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display) | |||
| 621 | + | out, err := cmd.Output() | |||
| 622 | + | if err != nil { | |||
| 623 | + | t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out) | |||
| 624 | + | } | |||
| 625 | + | return strings.TrimSpace(string(out)) | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // needWlClipboard skips the test without wl-copy and wl-paste. | |||
| 629 | + | func needWlClipboard(t *testing.T) { | |||
| 630 | + | t.Helper() | |||
| 631 | + | for _, c := range []string{"wl-copy", "wl-paste"} { | |||
| 632 | + | if _, err := exec.LookPath(c); err != nil { | |||
| 633 | + | t.Skipf("%s not on PATH (wl-clipboard)", c) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | // wlCopy starts wl-copy --foreground serving data on the headless | |||
| 639 | + | // compositor's seat and stops it when the test ends. | |||
| 640 | + | func wlCopy(t *testing.T, data string, args ...string) { | |||
| 641 | + | t.Helper() | |||
| 642 | + | cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...) | |||
| 643 | + | cmd.Env = waylandEnv() | |||
| 644 | + | cmd.Stdin = strings.NewReader(data) | |||
| 645 | + | stderr := &lockedBuffer{} | |||
| 646 | + | cmd.Stderr = stderr | |||
| 647 | + | if err := cmd.Start(); err != nil { | |||
| 648 | + | t.Fatal(err) | |||
| 649 | + | } | |||
| 650 | + | waited := make(chan struct{}) | |||
| 651 | + | go func() { _ = cmd.Wait(); close(waited) }() | |||
| 652 | + | t.Cleanup(func() { | |||
| 653 | + | _ = cmd.Process.Kill() | |||
| 654 | + | <-waited | |||
| 655 | + | if s := stderr.String(); s != "" { | |||
| 656 | + | t.Logf("wl-copy stderr: %s", s) | |||
| 657 | + | } | |||
| 658 | + | }) | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | // wlPaste returns what wl-paste prints, and whether it succeeded. | |||
| 662 | + | func wlPaste(args ...string) (string, bool) { | |||
| 663 | + | ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout) | |||
| 664 | + | defer cancel() | |||
| 665 | + | cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...) | |||
| 666 | + | cmd.Env = waylandEnv() | |||
| 667 | + | out, err := cmd.Output() | |||
| 668 | + | return string(out), err == nil | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // payload is n bytes of printable text that differ along the way, so a | |||
| 672 | + | // misplaced chunk is caught. | |||
| 673 | + | func payload(n int) string { | |||
| 674 | + | var b strings.Builder | |||
| 675 | + | for i := 0; b.Len() < n; i++ { | |||
| 676 | + | fmt.Fprintf(&b, "line %d é\n", i) | |||
| 677 | + | } | |||
| 678 | + | return b.String()[:n] | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // pasteLine is what x11probe -paste prints for want. | |||
| 682 | + | func pasteLine(want string) string { | |||
| 683 | + | if len(want) <= 4096 { | |||
| 684 | + | return fmt.Sprintf("paste %q", want) | |||
| 685 | + | } | |||
| 686 | + | return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want))) | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | // TestXwaylandClipboard: the clipboard and the primary selection cross the | |||
| 690 | + | // X11/Wayland boundary in both directions, text only, small and INCR sized. | |||
| 691 | + | func TestXwaylandClipboard(t *testing.T) { | |||
| 692 | + | needWlClipboard(t) | |||
| 693 | + | e := startXwayland(t) | |||
| 694 | + | startProbe(t, e.bin, e.display, "-title", "focus") | |||
| 695 | + | until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil }) | |||
| 696 | + | ||||
| 697 | + | for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} { | |||
| 698 | + | for _, size := range []int{4 << 10, 1 << 20} { | |||
| 699 | + | name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10) | |||
| 700 | + | text := payload(size) | |||
| 701 | + | t.Run("W2X/"+name, func(t *testing.T) { | |||
| 702 | + | var wargs []string | |||
| 703 | + | if sel.w != "" { | |||
| 704 | + | wargs = append(wargs, sel.w) | |||
| 705 | + | } | |||
| 706 | + | wlCopy(t, text, wargs...) | |||
| 707 | + | until(t, "x11probe pastes the Wayland copy", func() bool { | |||
| 708 | + | return runProbe(t, e, "-paste", sel.x) == pasteLine(text) | |||
| 709 | + | }) | |||
| 710 | + | }) | |||
| 711 | + | t.Run("X2W/"+name, func(t *testing.T) { | |||
| 712 | + | file := filepath.Join(t.TempDir(), "text") | |||
| 713 | + | if err := os.WriteFile(file, []byte(text), 0o600); err != nil { | |||
| 714 | + | t.Fatal(err) | |||
| 715 | + | } | |||
| 716 | + | p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s") | |||
| 717 | + | until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") }) | |||
| 718 | + | var wargs []string | |||
| 719 | + | if sel.w != "" { | |||
| 720 | + | wargs = append(wargs, sel.w) | |||
| 721 | + | } | |||
| 722 | + | var got string | |||
| 723 | + | until(t, "wl-paste returns the X copy", func() bool { | |||
| 724 | + | var ok bool | |||
| 725 | + | got, ok = wlPaste(wargs...) | |||
| 726 | + | return ok && got == text | |||
| 727 | + | }) | |||
| 728 | + | if got != text { | |||
| 729 | + | t.Errorf("wl-paste differs: %d bytes", len(got)) | |||
| 730 | + | } | |||
| 731 | + | }) | |||
| 732 | + | } | |||
| 733 | + | } | |||
| 734 | + | ||||
| 735 | + | t.Run("clear", func(t *testing.T) { | |||
| 736 | + | wlCopy(t, "to be cleared") | |||
| 737 | + | until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` }) | |||
| 738 | + | cmd := exec.Command("wl-copy", "--clear") | |||
| 739 | + | cmd.Env = waylandEnv() | |||
| 740 | + | if out, err := cmd.CombinedOutput(); err != nil { | |||
| 741 | + | t.Fatalf("wl-copy --clear: %v: %s", err, out) | |||
| 742 | + | } | |||
| 743 | + | until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" }) | |||
| 744 | + | }) | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | // cursorTheme installs a one-arrow Xcursor theme of the given size and | |||
| 748 | + | // selects it through the XCURSOR_* environment. | |||
| 749 | + | func cursorTheme(t *testing.T, size int) { | |||
| 750 | + | t.Helper() | |||
| 751 | + | dir := t.TempDir() | |||
| 752 | + | themeCursor(t, dir, "left_ptr", size, 1) | |||
| 753 | + | t.Setenv("XCURSOR_PATH", dir) | |||
| 754 | + | t.Setenv("XCURSOR_THEME", "theme") | |||
| 755 | + | t.Setenv("XCURSOR_SIZE", strconv.Itoa(size)) | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | // TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme, | |||
| 759 | + | // the cursor size and a fixed scale 1 / 96 dpi. | |||
| 760 | + | func TestXwaylandXSettings(t *testing.T) { | |||
| 761 | + | cursorTheme(t, 32) | |||
| 762 | + | e := startXwayland(t) | |||
| 763 | + | want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" | |||
| 764 | + | var got string | |||
| 765 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |||
| 766 | + | if got != want { | |||
| 767 | + | t.Errorf("-xsettings = %q, want %q", got, want) | |||
| 768 | + | } | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | // TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the | |||
| 772 | + | // DPI stays 96 even on a scale-2 output. | |||
| 773 | + | func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) { | |||
| 774 | + | cursorTheme(t, 24) | |||
| 775 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | |||
| 776 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true}) | |||
| 777 | + | }) | |||
| 778 | + | var got string | |||
| 779 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |||
| 780 | + | if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") { | |||
| 781 | + | t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got) | |||
| 782 | + | } | |||
| 783 | + | if !strings.Contains(got, "Gtk/CursorThemeSize=24") { | |||
| 784 | + | t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got) | |||
| 785 | + | } | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | // TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager. | |||
| 789 | + | func TestXwaylandXSettingsOff(t *testing.T) { | |||
| 790 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false }) | |||
| 791 | + | // A mapped window proves the XWM is up before the absence is checked. | |||
| 792 | + | p := startProbe(t, e.bin, e.display) | |||
| 793 | + | until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 794 | + | if got := runProbe(t, e, "-xsettings"); got != "xsettings none" { | |||
| 795 | + | t.Errorf("-xsettings = %q, want none", got) | |||
| 796 | + | } | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | // TestXwaylandRootCursor: the X background shows the themed arrow, at the | |||
| 800 | + | // theme size. | |||
| 801 | + | func TestXwaylandRootCursor(t *testing.T) { | |||
| 802 | + | cursorTheme(t, 32) | |||
| 803 | + | e := startXwayland(t) | |||
| 804 | + | until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" }) | |||
| 805 | + | if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" { | |||
| 806 | + | t.Errorf("-root-cursor = %q, want cursor 32x32", got) | |||
| 807 | + | } | |||
| 808 | + | } | |||
M internal/core/client.go +8 -0
| @@ -70,6 +70,14 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish, | |||||
| 70 | if _, w := c.screenOf(v.ID); w != nil { | 70 | if _, w := c.screenOf(v.ID); w != nil { | ||
| 71 | w.SetModal(v.ID, v.Modal) | 71 | w.SetModal(v.ID, v.Modal) | ||
| 72 | } | 72 | } | ||
| 73 | + | case ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped: | |||
| 74 | + | changed, err := c.unmanagedEvent(ctx, v) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return false, true | |||
| 77 | + | } | |||
| 78 | + | if !changed { | |||
| 79 | + | return false, false | |||
| 80 | + | } | |||
| 73 | case ports.WindowUnmapped: | 81 | case ports.WindowUnmapped: | ||
| 74 | if c.unmapClient(ctx, v) != nil { | 82 | if c.unmapClient(ctx, v) != nil { | ||
| 75 | return false, true | 83 | return false, true | ||
M internal/core/core.go +16 -5
| @@ -103,9 +103,11 @@ type Core struct { | |||||
| 103 | binds map[binding]Action | 103 | binds map[binding]Action | ||
| 104 | pressed map[string]bool | 104 | pressed map[string]bool | ||
| 105 | configures configures | 105 | configures configures | ||
| 106 | - | pointer WindowID | 106 | + | // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window. |
| 107 | - | grab WindowID | 107 | + | placed map[WindowID]Rect |
| 108 | - | buttons map[uint32]bool | 108 | + | pointer WindowID |
| 109 | + | grab WindowID | |||
| 110 | + | buttons map[uint32]bool | |||
| 109 | // drag is the pointer drag in progress (drag.go); swallow holds the | 111 | // drag is the pointer drag in progress (drag.go); swallow holds the | ||
| 110 | // buttons whose release no client must see. mods are the modifiers | 112 | // buttons whose release no client must see. mods are the modifiers | ||
| 111 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | 113 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | ||
| @@ -144,7 +146,11 @@ type Core struct { | |||||
| 144 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | 146 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | ||
| 145 | popups map[WindowID]*popupState | 147 | popups map[WindowID]*popupState | ||
| 146 | popupOrder []WindowID | 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 | // activity is when core last told wayland about user input | 154 | // activity is when core last told wayland about user input | ||
| 149 | // (ports.UserActivity), sent at most once per ActivityInterval. | 155 | // (ports.UserActivity), sent at most once per ActivityInterval. | ||
| 150 | activity time.Time | 156 | activity time.Time | ||
| @@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) { | |||||
| 417 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | 423 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | ||
| 418 | } | 424 | } | ||
| 419 | // A placeholder screen holds windows until the first output arrives. | 425 | // A placeholder screen holds windows until the first output arrives. | ||
| 420 | - | c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()} | 426 | + | c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, unmanaged: map[WindowID]*unmanagedState{}, unmanagedOrder: make([]WindowID, 0, 32), ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), placed: map[WindowID]Rect{}, ping: make(chan chan struct{}), out: newOutbox()} |
| 421 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | 427 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | ||
| 422 | if err := c.apply(cfg); err != nil { | 428 | if err := c.apply(cfg); err != nil { | ||
| 423 | return nil, err | 429 | return nil, err | ||
| @@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 994 | } | 1000 | } | ||
| 995 | sc := c.screens[i] | 1001 | sc := c.screens[i] | ||
| 996 | lx, ly := x-float64(sc.x), y-float64(sc.y) | 1002 | lx, ly := x-float64(sc.x), y-float64(sc.y) | ||
| 1003 | + | // Unmanaged windows are over everything, popups included; a locked | |||
| 1004 | + | // pointer keeps its focus (a mouse-look game). | |||
| 1005 | + | if id, px, py := c.unmanagedAt(sc, lx, ly); id != 0 { | |||
| 1006 | + | return id, px, py | |||
| 1007 | + | } | |||
| 997 | // Layer popups are over everything; window popups are over the windows | 1008 | // Layer popups are over everything; window popups are over the windows | ||
| 998 | // only, under the top and overlay layers (as drawn). | 1009 | // only, under the top and overlay layers (as drawn). | ||
| 999 | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | 1010 | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | ||
M internal/core/core_test.go +52 -3
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "reflect" | 6 | "reflect" | ||
| 7 | + | "regexp" | |||
| 7 | "slices" | 8 | "slices" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "testing/synctest" | 10 | "testing/synctest" | ||
| @@ -38,6 +39,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||||
| 38 | for { | 39 | for { | ||
| 39 | switch v := receive(t, ch); v.(type) { | 40 | switch v := receive(t, ch); v.(type) { | ||
| 40 | case nil, ports.SetOutputs, ports.UserActivity: | 41 | case nil, ports.SetOutputs, ports.UserActivity: | ||
| 42 | + | case ports.PlaceWindow: | |||
| 43 | + | // Only Positioned (X11) windows are placed; every scenario | |||
| 44 | + | // using this helper maps xdg windows. | |||
| 45 | + | t.Fatalf("unexpected %+v", v) | |||
| 41 | default: | 46 | default: | ||
| 42 | return v | 47 | return v | ||
| 43 | } | 48 | } | ||
| @@ -1028,7 +1033,7 @@ func fullscreenAtMapIgnored(t *testing.T, animated bool) { | |||||
| 1028 | // Under fixed overflow, a window opening over a fullscreen game waits | 1033 | // Under fixed overflow, a window opening over a fullscreen game waits | ||
| 1029 | // hidden: the game keeps the screen and the keyboard. An activation (a | 1034 | // hidden: the game keeps the screen and the keyboard. An activation (a | ||
| 1030 | // user action in the new client) leaves fullscreen and shows both tiles; | 1035 | // user action in the new client) leaves fullscreen and shows both tiles; | ||
| 1031 | - | // a focus move back to the game and the bind make it fullscreen again. | 1036 | + | // a focus move back to the game makes it fullscreen again. |
| 1032 | func TestFixedFullscreenArrivalWaits(t *testing.T) { | 1037 | func TestFixedFullscreenArrivalWaits(t *testing.T) { | ||
| 1033 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | 1038 | r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left) | ||
| 1034 | r.mapWindow(t, 1) | 1039 | r.mapWindow(t, 1) | ||
| @@ -1050,8 +1055,7 @@ func TestFixedFullscreenArrivalWaits(t *testing.T) { | |||||
| 1050 | break | 1055 | break | ||
| 1051 | } | 1056 | } | ||
| 1052 | } | 1057 | } | ||
| 1053 | - | r.key(t, "Left", ports.ModAlt) | 1058 | + | set = r.key(t, "Left", ports.ModAlt) |
| 1054 | - | set = r.key(t, "f", ports.ModAlt|ports.ModShift) | |||
| 1055 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | 1059 | if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1}) || focused != 1 { | ||
| 1056 | t.Fatal("game again:", got, focused) | 1060 | t.Fatal("game again:", got, focused) | ||
| 1057 | } | 1061 | } | ||
| @@ -1250,3 +1254,48 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||||
| 1250 | } | 1254 | } | ||
| 1251 | 1255 | ||||
| 1252 | } | 1256 | } | ||
| 1257 | + | ||||
| 1258 | + | // A window that maps with the fullscreen state (an X11 game with | |||
| 1259 | + | // _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused, | |||
| 1260 | + | // in a tile of its own even when it was classified floating; a rule that | |||
| 1261 | + | // floats it does not change that. The grace still drops a later request. | |||
| 1262 | + | func TestMappedFullscreenHonoured(t *testing.T) { | |||
| 1263 | + | for _, tc := range []struct { | |||
| 1264 | + | name string | |||
| 1265 | + | rule bool | |||
| 1266 | + | }{{"fixed size", false}, {"floating rule", true}} { | |||
| 1267 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1268 | + | cfg := scrollDefaults() | |||
| 1269 | + | cfg.Border.Width = 0 | |||
| 1270 | + | if tc.rule { | |||
| 1271 | + | on := true | |||
| 1272 | + | cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}} | |||
| 1273 | + | } | |||
| 1274 | + | client := make(chan ports.ClientEvent, 8) | |||
| 1275 | + | output := make(chan ports.OutputEvent, 8) | |||
| 1276 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 1277 | + | scenes := make(chan []ports.Scene, 1) | |||
| 1278 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)}) | |||
| 1279 | + | if err != nil { | |||
| 1280 | + | t.Fatal(err) | |||
| 1281 | + | } | |||
| 1282 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1283 | + | defer cancel() | |||
| 1284 | + | go c.Run(ctx) | |||
| 1285 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 1286 | + | scene(t, scenes) | |||
| 1287 | + | client <- ports.WindowMapped{ID: 1} | |||
| 1288 | + | scene(t, scenes) | |||
| 1289 | + | client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true} | |||
| 1290 | + | s := scene(t, scenes) | |||
| 1291 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) { | |||
| 1292 | + | s = scene(t, scenes) | |||
| 1293 | + | } | |||
| 1294 | + | for _, w := range s.Windows { | |||
| 1295 | + | if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) { | |||
| 1296 | + | t.Fatalf("fullscreen rect %+v, want the output", w.Rect) | |||
| 1297 | + | } | |||
| 1298 | + | } | |||
| 1299 | + | }) | |||
| 1300 | + | } | |||
| 1301 | + | } | |||
M internal/core/datadrag.go +1 -1
| @@ -101,7 +101,7 @@ func dataDragHitChanged(ev ports.ClientEvent) bool { | |||||
| 101 | case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped, | 101 | case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped, | ||
| 102 | ports.WindowResized, ports.PopupRequest, ports.PopupMapped, | 102 | ports.WindowResized, ports.PopupRequest, ports.PopupMapped, | ||
| 103 | ports.OutputPower, ports.WindowParent, ports.WindowModal, | 103 | ports.OutputPower, ports.WindowParent, ports.WindowModal, | ||
| 104 | - | ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest, | 104 | + | ports.WindowUnmapped, ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped, ports.PointerWarp, ports.WindowMoveRequest, |
| 105 | ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate: | 105 | ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate: | ||
| 106 | return true | 106 | return true | ||
| 107 | default: | 107 | default: | ||
M internal/core/fullscreen_left_core_test.go +1 -6
| @@ -64,13 +64,8 @@ func TestCoreLeftFullscreenLatch(t *testing.T) { | |||||
| 64 | if id := full(); id != 0 { | 64 | if id := full(); id != 0 { | ||
| 65 | t.Fatalf("re-request covered: fullscreen %d", id) | 65 | t.Fatalf("re-request covered: fullscreen %d", id) | ||
| 66 | } | 66 | } | ||
| 67 | + | // Focus back on 2 restores its fullscreen: an X11 client never re-asks. | |||
| 67 | client <- ports.WindowActivate{ID: 2} | 68 | client <- ports.WindowActivate{ID: 2} | ||
| 68 | - | full() | |||
| 69 | - | client <- ports.WindowActivate{ID: 1} | |||
| 70 | - | full() | |||
| 71 | - | // The latch settled when 2 had the focus: scroll overflow honours an | |||
| 72 | - | // unfocused window's request again. | |||
| 73 | - | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |||
| 74 | if id := full(); id != 2 { | 69 | if id := full(); id != 2 { | ||
| 75 | t.Fatalf("after refocus: fullscreen %d, want 2", id) | 70 | t.Fatalf("after refocus: fullscreen %d, want 2", id) | ||
| 76 | } | 71 | } | ||
M internal/core/fullscreen_left_test.go +29 -0
| @@ -111,3 +111,32 @@ func TestLeftFullscreenRemoved(t *testing.T) { | |||||
| 111 | t.Fatalf("left %d", w.left) | 111 | t.Fatalf("left %d", w.left) | ||
| 112 | } | 112 | } | ||
| 113 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | // Focus back on a window whose fullscreen the user left restores it, with | |||
| 116 | + | // no request from the client (X11 clients never re-ask), and allocates | |||
| 117 | + | // nothing once settled. | |||
| 118 | + | func TestLeftFullscreenRestoredOnFocus(t *testing.T) { | |||
| 119 | + | for _, tc := range pinnedCases { | |||
| 120 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 121 | + | m := coverMonitor(tc.overflow, tc.float) | |||
| 122 | + | w := m.Current() | |||
| 123 | + | w.Activate(1) | |||
| 124 | + | if w.fullscreen != 0 || w.left != 2 { | |||
| 125 | + | t.Fatalf("leave: fullscreen %d, left %d", w.fullscreen, w.left) | |||
| 126 | + | } | |||
| 127 | + | w.Activate(2) | |||
| 128 | + | w.settleLeft() | |||
| 129 | + | if w.fullscreen != 2 || w.left != 0 { | |||
| 130 | + | t.Fatalf("back: fullscreen %d, left %d", w.fullscreen, w.left) | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestSettleLeftAllocs(t *testing.T) { | |||
| 137 | + | m := coverMonitor(OverflowScroll, false) | |||
| 138 | + | w := m.Current() | |||
| 139 | + | if n := testing.AllocsPerRun(100, w.settleLeft); n != 0 { | |||
| 140 | + | t.Fatalf("settleLeft allocs %v", n) | |||
| 141 | + | } | |||
| 142 | + | } | |||
M internal/core/layout.go +7 -1
| @@ -1435,6 +1435,9 @@ func (w *Workspace) SetFullscreen(id WindowID, on bool) { | |||||
| 1435 | return | 1435 | return | ||
| 1436 | } | 1436 | } | ||
| 1437 | if !on { | 1437 | if !on { | ||
| 1438 | + | if w.left == id { | |||
| 1439 | + | w.left = 0 // the client itself gave up: nothing to restore | |||
| 1440 | + | } | |||
| 1438 | if w.fullscreen == id { | 1441 | if w.fullscreen == id { | ||
| 1439 | w.endFullscreen() | 1442 | w.endFullscreen() | ||
| 1440 | } | 1443 | } | ||
| @@ -1522,7 +1525,10 @@ func (w *Workspace) gap() int { return min(w.Gaps, w.Usable.W/2, w.Usable.H/2) } | |||||
| 1522 | // settleLeft drops the latch once its window has the focus again, however | 1525 | // settleLeft drops the latch once its window has the focus again, however | ||
| 1523 | // it got it; core calls it after each event. | 1526 | // it got it; core calls it after each event. | ||
| 1524 | func (w *Workspace) settleLeft() { | 1527 | func (w *Workspace) settleLeft() { | ||
| 1525 | - | if id, ok := w.Focused(); ok && id == w.left { | 1528 | + | if id, ok := w.Focused(); ok && id != 0 && id == w.left { |
| 1529 | + | // Focus is back: the window gets its fullscreen back, as a client | |||
| 1530 | + | // that re-asks would (X11 clients never do). | |||
| 1531 | + | w.SetFullscreen(id, true) | |||
| 1526 | w.left = 0 | 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 | t.Fatalf("%+v", s.Windows) | 411 | t.Fatalf("%+v", s.Windows) | ||
| 412 | } | 412 | } | ||
| 413 | } | 413 | } | ||
| 414 | + | ||||
| 415 | + | func TestMapAtOutputPlacesOnThatOutput(t *testing.T) { | |||
| 416 | + | r := startMulti(t, nil, left, right) | |||
| 417 | + | r.mapApp(t, 1, "term") | |||
| 418 | + | // DP-2 starts at layout x=200: a game mapping at its rect goes there. | |||
| 419 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 420 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-2" { | |||
| 421 | + | t.Fatalf("not on DP-2: %+v", w) | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func TestMapAtOutputKeepsRuleMonitor(t *testing.T) { | |||
| 426 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 427 | + | c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Monitor = "DP-1" })} | |||
| 428 | + | }, left, right) | |||
| 429 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 430 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" { | |||
| 431 | + | t.Fatalf("rule monitor lost to the hint: %+v", w) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | func TestMapAtOutputKeepsRuleWorkspace(t *testing.T) { | |||
| 436 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 437 | + | c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Workspace = "1" })} | |||
| 438 | + | }, left, right) | |||
| 439 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 440 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" { | |||
| 441 | + | t.Fatalf("rule workspace lost to the hint: %+v", w) | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestRuleMatchesSteamAppClass(t *testing.T) { | |||
| 446 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 447 | + | c.Rules = []ports.WindowRule{winRule("steam", `steam_app_\d+`, func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |||
| 448 | + | }, left) | |||
| 449 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "steam_app_892970", Positioned: true} | |||
| 450 | + | if w := stWindow(r.stateWith(t, 1), 1); !w.Floating { | |||
| 451 | + | t.Fatalf("steam_app rule did not match: %+v", w) | |||
| 452 | + | } | |||
| 453 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "steam_app_x", Positioned: true} | |||
| 454 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Floating { | |||
| 455 | + | t.Fatalf("non-numeric id matched: %+v", w) | |||
| 456 | + | } | |||
| 457 | + | } | |||
M internal/core/scene.go +12 -1
| @@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 20 | // layout is what is drawn; settled (same indexes) is where the | 20 | // layout is what is drawn; settled (same indexes) is where the | ||
| 21 | // windows are going, and alone sizes the configures. | 21 | // windows are going, and alone sizes the configures. | ||
| 22 | layout, settled := sc.shown, sc.settledLayout | 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 | if !sc.mon.previewing() { | 24 | if !sc.mon.previewing() { | ||
| 25 | // Tiles stop at the panels; the overview lays out previews over | 25 | // Tiles stop at the panels; the overview lays out previews over | ||
| 26 | // the whole output. | 26 | // the whole output. | ||
| @@ -116,8 +116,19 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 116 | } | 116 | } | ||
| 117 | c.configures.mark(v) | 117 | c.configures.mark(v) | ||
| 118 | } | 118 | } | ||
| 119 | + | if !p.Hidden && p.Preview == 0 && c.windows.lookup(p.ID).client.Positioned { | |||
| 120 | + | cr := c.clientRect(ps) | |||
| 121 | + | at := Rect{X: sc.x + cr.X, Y: sc.y + cr.Y} | |||
| 122 | + | if old, ok := c.placed[p.ID]; !ok || old != at { | |||
| 123 | + | if err := c.command(ctx, ports.PlaceWindow{ID: p.ID, X: at.X, Y: at.Y}); err != nil { | |||
| 124 | + | return ports.Scene{}, err | |||
| 125 | + | } | |||
| 126 | + | c.placed[p.ID] = at | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 119 | } | 129 | } | ||
| 120 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | 130 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | ||
| 131 | + | scene.Windows = c.sceneUnmanaged(scene.Windows, sc) | |||
| 121 | if icon, ok := c.dataDragIconOn(i, sc); ok { | 132 | if icon, ok := c.dataDragIconOn(i, sc); ok { | ||
| 122 | // The drag icon is over everything, popups and layers included. | 133 | // The drag icon is over everything, popups and layers included. | ||
| 123 | scene.Windows = append(scene.Windows, icon) | 134 | scene.Windows = append(scene.Windows, icon) | ||
M internal/core/spawn_placement.go +9 -1
M internal/core/transition.go +20 -0
A internal/core/unmanaged.go +117 -0
A internal/core/unmanaged_test.go +145 -0
M internal/ports/client_commands.go +10 -0
M internal/ports/client_events.go +37 -0
M internal/ports/config.go +35 -3
Comments0 open / 0
no comments yet.