detached 1f6c234

Merge 17529ce1e512f84fe75f8eb022e1118eabbe015f into 3c13b3d3cf1deae3f997dd5bb3e9e0a55049380f

commit
1f6c234944b9b01d933a650aeb07729405ffc935
author
ermite · · 2026-10-22 14:01 UTC
merge of
3c13b3d + 17529ce · diff against the first parent
signature
unsigned
74 files · +11381 -144
M .mockery.yml +20 -0
@@ -182,3 +182,23 @@ packages:
182182 dir: internal/adapters/screencast
183183 pkgname: screencast
184184 filename: pipewire_opener_mock_test.go
185+
186+ github.com/bnema/neferwl/internal/adapters/xwayland:
187+ interfaces:
188+ starter:
189+ config:
190+ dir: internal/adapters/xwayland
191+ pkgname: xwayland
192+ filename: starter_mock_test.go
193+ github.com/bnema/neferwl/internal/adapters/wayland/xwm:
194+ interfaces:
195+ host:
196+ config:
197+ dir: internal/adapters/wayland/xwm
198+ pkgname: xwm
199+ filename: host_mock_test.go
200+ fdSource:
201+ config:
202+ dir: internal/adapters/wayland/xwm
203+ pkgname: xwm
204+ filename: fdsource_mock_test.go
M Makefile +9 -2
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
2626 CGO_ENABLED=0 go test ./...
2727 vet:
2828 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
2930 race:
3031 # cgo is enabled only for the race test binary.
3132 CGO_ENABLED=1 go test -race ./...
@@ -69,7 +70,8 @@ adapter-imports:
6970 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7071 # Fast, mandatory guards for tiled content publications and SHM copies.
7172 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1
73+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations)$$' -count=1
74+ CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7375 check: vet test arch fakes-check log-check perf-check
7476
7577 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -77,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
7779 color-check:
7880 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
7981
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
8087 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
8188 # come from the module proxy in prepare(); the build itself runs offline.
8289 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
M README.md +1 -1
@@ -149,7 +149,7 @@ See [Configuration](docs/config.md) for all keys, actions, bindings and automati
149149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
150150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
151151 - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
152-- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.
152+- Steam and other X11 clients run through xwayland-satellite, or through the experimental built-in window manager (`xwayland = builtin`; no menus, clipboard or XSETTINGS yet). Wine can run natively on Wayland.
153153
154154 ### Windows and workspaces
155155
M docs/config.md +3 -1
@@ -27,7 +27,7 @@ See [examples/config](../examples/config) for every key with its default.
2727 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
2828 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
2929 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
3131 | `background` | `#111111` | Background color |
3232 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
3333 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +103,8 @@ destination output, even if that output has no windows. Same-screen workspace
103103 switches and mouse focus do not trigger a flash.
104104 It runs when `animations = on`.
105105
106+`xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus and popups (override-redirect windows), clipboard and XSETTINGS are not supported yet; use `satellite` if you need them.
107+
106108 ## Automatic terminal
107109
108110 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
M docs/desktop.md +3 -1
@@ -52,12 +52,14 @@ Candidate popups are not drawn yet.
5252
5353 ## X11 apps
5454
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
5656
5757 ```text
5858 xwayland = off # no X11 support
5959 ```
6060
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus and popups, clipboard and XSETTINGS are not supported yet; use `satellite` if you need them.
62+
6163 ## Session locking
6264
6365 NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
M docs/dev/headless.md +13 -0
@@ -62,6 +62,19 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本
6262
6363 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.
6464
65+## X11 check
66+
67+`make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland.
68+
69+The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. It checks:
70+
71+- the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile;
72+- the probe receives focus;
73+- a second probe tiles beside the first;
74+- `key Super+q` closes the focused probe within 2 seconds and the state drops to one window.
75+
76+Run it by hand with `go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v`.
77+
6578 ## Colour check
6679
6780 `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`.
M go.mod +4 -3
@@ -3,10 +3,11 @@ module github.com/bnema/neferwl
33 go 1.27
44
55 require (
6+ bnema.dev/xturbo v0.1.0
67 github.com/bnema/go-wayland-bindings v0.1.0
78 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.1
9- github.com/bnema/purego-libwayland v0.11.0
9+ github.com/bnema/purego v0.13.0-bnema.2
10+ github.com/bnema/purego-libwayland v0.12.0
1011 github.com/bnema/purego-pipewire v0.2.0
1112 github.com/bnema/purego-vulkan v0.6.0
1213 github.com/bnema/wlturbo v0.5.0
@@ -16,7 +17,7 @@ require (
1617 github.com/rs/zerolog v1.35.1
1718 github.com/stretchr/testify v1.12.1
1819 golang.org/x/image v0.46.0
19- golang.org/x/sys v0.48.0
20+ golang.org/x/sys v0.49.0
2021 golang.org/x/term v0.46.0
2122 )
2223
M go.sum +8 -6 generated file: collapsed
M internal/adapters/config/config.go +8 -4
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167167 // Hint mode is experimental: off unless the config turns it on.
168168 c.Hint.Enable = false
169169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"
170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
171171 c.Background.Color = "#111111"
172172 c.Floating.Dim = 0.3
173173 c.Stash.Width = 80
@@ -726,11 +726,15 @@ func set(c *ports.Config, key, v string) error {
726726 case "xwayland":
727727 switch {
728728 case v == "off":
729- c.Xwayland = ""
729+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff}
730+ case v == "satellite":
731+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
732+ case v == "builtin":
733+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
730734 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")
735+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732736 default:
733- c.Xwayland = v
737+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v}
734738 }
735739 case "background":
736740 if !color.MatchString(v) {
M internal/adapters/config/config_test.go +16 -6
@@ -340,14 +340,24 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340340 }
341341
342342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {
343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}) {
344344 t.Fatal(Defaults().Xwayland)
345345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
347- t.Fatal(c.Xwayland, w)
348- }
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
350- t.Fatal(c.Xwayland, w)
346+ for _, tt := range []struct {
347+ value string
348+ want ports.XwaylandConfig
349+ }{
350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls"}},
354+ } {
355+ t.Run(tt.value, func(t *testing.T) {
356+ c, w := parseString(t, "xwayland = "+tt.value)
357+ if c.Xwayland != tt.want || len(w) != 0 {
358+ t.Fatal(c.Xwayland, w)
359+ }
360+ })
351361 }
352362 }
353363
M internal/adapters/vulkan/capture_test.go +3 -0
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358358 // slot keeps its sync file for every lease. No mock stands in the measured
359359 // closure.
360360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361364 for _, tc := range []struct {
362365 name string
363366 new func(*testing.T) *Renderer
A internal/adapters/vulkan/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
A internal/adapters/vulkan/race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
M internal/adapters/vulkan/scene_perf_test.go +6 -0
@@ -16,6 +16,9 @@ import (
1616 // A new scene Seq with one of two windows moved is a partial redraw seeded by
1717 // sceneDelta and allocates nothing either.
1818 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
1922 for _, tc := range []struct {
2023 name string
2124 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145148 // keeps one alloc of headroom for drivers whose import path differs.
146149 // Skipped without a GPU or /dev/udmabuf.
147150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148154 r, err := New(80, 32)
149155 if err != nil {
150156 t.Skipf("Vulkan unavailable: %v", err)
M internal/adapters/wayland/activation.go +3 -3
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
6666 }
6767 var w *window
6868 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
7070 reason = "not_toplevel"
7171 }
7272 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116116
117117 // focusClient is the client with the keyboard focus.
118118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {
120- return w.xdg.resource.Client()
119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120+ return w.client()
121121 }
122122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123123 return l.resource.Client()
M internal/adapters/wayland/constraints.go +14 -13
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
6161 return
6262 }
6363 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {
64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
6565 return
6666 }
6767 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry
68+ g := w.geometry()
6969 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
7070 }
7171
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
8080 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
8181 return false
8282 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {
83+ if !w.mapped || !w.alive() {
8484 return false
8585 }
8686 sent := false
8787 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {
88+ for _, rel := range s.relatives[w.client()] {
8989 if rel.Resource.Alive() {
9090 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
9191 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272272 }
273273 var want *constraint
274274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276276 want = c
277277 }
278278 }
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298298 if s.protected() {
299299 return
300300 }
301- x := c.surface.xdg
302- if x == nil || x.window == nil {
301+ w := c.surface.toplevelWindow()
302+ if w == nil {
303303 return
304304 }
305305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308308 }
309309 r := c.region
310310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X
312- r.Y -= x.geometry.Y
311+ g := w.geometry()
312+ r.X -= g.X
313+ r.Y -= g.Y
313314 }
314315 // Core supports one confinement rect, so this bounds the intersection;
315316 // contains uses the exact input region to decide activation.
@@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) {
333334 r = box
334335 }
335336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337338 }
338339
339340 // contains reports whether the pointer is in the region: a constraint
340341 // activates only there, so it never warps the pointer.
341342 func (c *constraint) contains(w *window) bool {
342343 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)
344+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344345 all, rects := c.surface.effectiveInput()
345346 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
347+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347348 inside := false
348349 for _, input := range rects {
349350 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +364,5 @@ func (c *constraint) contains(w *window) bool {
363364
364365 // locked reports whether the window's pointer is locked.
365366 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
367+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367368 }
M internal/adapters/wayland/decoration.go +1 -1
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
8080 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
8181 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
8282 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
8484 w.sendConfigure()
8585 return
8686 }
M internal/adapters/wayland/dialog.go +1 -1
@@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) {
7777 }
7878 w.modal = modal
7979 if w.mapped {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
8181 }
8282 }
M internal/adapters/wayland/dmabuf.go +3 -2
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223223 return "", false
224224 }
225225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {
226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227228 return "", false
228229 }
229- return root.xdg.window.last.Output, true
230+ return w.last.Output, true
230231 }
231232
232233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
7272 func (s *Server) mappedToplevels() []ports.WindowID {
7373 ids := make([]ports.WindowID, 0, len(s.windows))
7474 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {
75+ if w.isToplevel() && w.mapped {
7676 ids = append(ids, id)
7777 }
7878 }
M internal/adapters/wayland/foreigntoplevel.go +6 -6
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
4444 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
4545 ids := make([]ports.WindowID, 0, len(s.windows))
4646 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {
47+ if w.isToplevel() && w.mapped {
4848 ids = append(ids, id)
4949 }
5050 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186186
187187 // toplevelChanged updates every manager after w mapped or changed.
188188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {
189+ if !w.isToplevel() || !w.mapped {
190190 return
191191 }
192192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220220 for _, l := range s.extToplevelLists {
221221 for id, h := range l.handles {
222222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224224 l.closed(id)
225225 }
226226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231231 for _, m := range s.toplevelManagers {
232232 for id, h := range m.handles {
233233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235235 m.closed(id)
236236 }
237237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253253 if t.s.protected() || t.closed {
254254 return nil
255255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {
256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257257 return w
258258 }
259259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274274 }
275275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()
277+ w.sendClose()
278278 }
279279 }
280280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
M internal/adapters/wayland/frames.go +8 -6
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183183 func toplevelRoot(surf *surface) *surface {
184184 surf = surf.root()
185185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()
186+ surf = surf.xdg.window.popup.parent.surf().root()
187187 }
188188 return surf
189189 }
@@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface {
193193 // for a capture session is invisible.
194194 func (s *Server) invisible(surf *surface) bool {
195195 root := toplevelRoot(surf)
196- return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
196+ w := root.toplevelWindow()
197+ return w != nil && w.hasLast && !w.last.Visible
197198 }
198199
199200 // suspended reports that nothing draws the toplevel tree, not even a capture
@@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool {
201202 // frame callbacks of its output so the capture keeps moving.
202203 func (s *Server) suspended(surf *surface) bool {
203204 root := toplevelRoot(surf)
204- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast {
205+ w := root.toplevelWindow()
206+ if w == nil || !w.hasLast {
205207 return false
206208 }
207- return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured
209+ return !w.last.Visible && !w.last.Captured
208210 }
209211
210212 // frameOutput is the pacing class for a surface; "" is the 60 Hz
@@ -212,11 +214,11 @@ func (s *Server) suspended(surf *surface) bool {
212214 func (s *Server) frameOutput(surf *surface) string {
213215 surf = toplevelRoot(surf)
214216 switch {
215- case surf.xdg != nil && surf.xdg.window != nil:
217+ case surf.toplevelWindow() != nil:
216218 if s.suspended(surf) {
217219 return suspendedFrameQueue
218220 }
219- return surf.xdg.window.last.Output
221+ return surf.toplevelWindow().last.Output
220222 case surf.layer != nil && surf.layer.output != nil:
221223 return surf.layer.output.name()
222224 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
M internal/adapters/wayland/globals.go +10 -1
@@ -13,6 +13,7 @@ import (
1313 func registerGlobals(d *server.Display, o Options, s *Server) error {
1414 for _, register := range []func() error{
1515 func() error { return registerXDG(d, s) },
16+ func() error { return registerXwaylandShell(d, s) },
1617 func() error { return registerLayer(d, s) },
1718 func() error {
1819 // Advertise only with the production owner gate and authoritative
@@ -131,8 +132,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131132 state.identity = c.server.nextSurface
132133 state.wl = w
133134 c.server.surfaces[w.Resource] = state
135+ c.server.surfaceByIdentity[state.identity] = state
134136 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
137+ w.OnDestroy = func() {
138+ delete(c.server.surfaces, w.Resource)
139+ delete(c.server.surfaceByIdentity, state.identity)
140+ state.Destroy(w)
141+ if state.kind == roleX11 && c.server.xwm != nil {
142+ c.server.xwm.SurfaceGone(state.identity)
143+ }
144+ }
136145 }
137146 }
138147 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
M internal/adapters/wayland/imagecopy.go +1 -1
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351351 case srcWindow:
352352 // Core's state gives the size; before it, the last configure.
353353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355355 return nil, image.Rectangle{}, false, false
356356 }
357357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
M internal/adapters/wayland/popup.go +2 -2
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
3131 var parent *window
3232 if parentXDG != nil {
3333 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
3535 parent = w
3636 }
3737 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167167 var top *window
168168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {
169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170170 top = w
171171 }
172172 }
M internal/adapters/wayland/scale.go +3 -3
@@ -21,12 +21,12 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2121 surf = surf.root()
2222 // Popups are on their parent's output.
2323 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()
24+ surf = surf.xdg.window.popup.parent.surf().root()
2525 }
2626 name := ""
2727 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:
29- name = surf.xdg.window.last.Output
28+ case surf.toplevelWindow() != nil:
29+ name = surf.toplevelWindow().last.Output
3030 case surf.layer != nil && surf.layer.output != nil:
3131 return surf.layer.output
3232 case surf.lock != nil:
M internal/adapters/wayland/screencast.go +1 -1
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146146 gone := ports.CaptureReasonOutputGone
147147 if cmd.Window != 0 {
148148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151151 open.Window = cmd.Window
152152 }
M internal/adapters/wayland/seat.go +9 -8
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276276 }
277277
278278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
279+ if w == nil || !w.mapped || !w.alive() {
280280 return nil
281281 }
282- return s.clientKeyboards(w.xdg.resource.Client())
282+ return s.clientKeyboards(w.client())
283283 }
284284
285285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -301,7 +301,7 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
301301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302302 }
303303 if w := s.windows[id]; w != nil && w.mapped {
304- if surf := w.xdg.surfaceResource(); surf != nil {
304+ if surf := w.surfaceResource(); surf != nil {
305305 return surf, s.windowKeyboards(w)
306306 }
307307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true
322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323+ return w.client(), s.windowPointers(w), true
324324 }
325325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336336 }
337337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339339 x, y := w.surfacePoint(x, y)
340340 return surf, s.windowPointers(w), x, y
341341 }
@@ -455,6 +455,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
455455 }
456456 }
457457 s.textInputFocus()
458+ s.x11Focus()
458459 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459460 s.updateConstraint()
460461 }
@@ -543,10 +544,10 @@ func pointerFrame(p *wayland.Pointer) {
543544 }
544545 }
545546 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
547+ if w == nil || !w.mapped || !w.alive() {
547548 return nil
548549 }
549- return s.clientPointers(w.xdg.resource.Client())
550+ return s.clientPointers(w.client())
550551 }
551552
552553 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
M internal/adapters/wayland/server.go +36 -16
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/neferwl/internal/adapters/captureallow"
1717 "github.com/bnema/neferwl/internal/adapters/clock"
1818 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
19+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
1920 "github.com/bnema/neferwl/internal/adapters/workspaceid"
2021 "github.com/bnema/neferwl/internal/ports"
2122 "github.com/bnema/purego-libwayland/server"
@@ -160,13 +161,18 @@ type Server struct {
160161 securityStop int
161162 securityWG sync.WaitGroup
162163 securityLive atomic.Int32
163- display *server.Display
164- env procEnv
165- name string
166- cleanup func()
167- log zerowrap.Logger
168- logDamage bool
169- channels Channels
164+ xwayland xwaylandClient // display owner
165+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
166+ // slots to their windows. Display goroutine only.
167+ xwm *xwm.XWM
168+ x11Slots map[xwm.Slot]*window
169+ display *server.Display
170+ env procEnv
171+ name string
172+ cleanup func()
173+ log zerowrap.Logger
174+ logDamage bool
175+ channels Channels
170176 // awaiting holds frame callbacks by output name, due at its next
171177 // frame (frameDue, or its page flip).
172178 awaiting map[string][]*wayland.Callback
@@ -214,8 +220,11 @@ type Server struct {
214220 // foreignExports are the live xdg-foreign handles (foreign.go).
215221 foreignExports map[string]*foreignExport
216222 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer
218- dmabuf *dmabufGlobal
223+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
224+ // pairing); kept with surfaces.
225+ surfaceByIdentity map[uint64]*surface
226+ buffers map[*server.Resource]clientBuffer
227+ dmabuf *dmabufGlobal
219228 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220229 hdrOutputs map[string]ports.OutputHDR
221230 colorID uint64
@@ -366,8 +375,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
366375 logDamage: opts.LogDamage,
367376 channels: ch,
368377 surfaces: make(map[*server.Resource]*surface),
378+ surfaceByIdentity: make(map[uint64]*surface),
369379 buffers: make(map[*server.Resource]clientBuffer),
370380 windows: make(map[ports.WindowID]*window),
381+ x11Slots: make(map[xwm.Slot]*window),
371382 layers: make(map[ports.WindowID]*layerSurface),
372383 nextWindow: 1,
373384 eventReady: make(chan struct{}, 1),
@@ -498,6 +509,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
498509 if err = s.openPaceTimer(); err != nil {
499510 return abort(err)
500511 }
512+ d.SetGlobalFilter(s.globalVisible)
501513 if err = registerGlobals(d, opts, s); err != nil {
502514 return abort(err)
503515 }
@@ -585,6 +597,12 @@ func (s *Server) Run(ctx context.Context) error {
585597 }
586598 s.castInventoryChanged()
587599 err := s.display.Run(ctx)
600+ // The display is gone: only the X connection is left to close.
601+ if s.xwm != nil {
602+ s.xwm.CloseConn()
603+ s.xwm = nil
604+ }
605+ s.forgetXwayland()
588606 s.stopSecurityContexts()
589607 wg.Wait()
590608 return err
@@ -774,9 +792,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
774792 if w := s.windows[c.ID]; w != nil && w.popup != nil {
775793 w.popup.dismiss()
776794 }
795+ case ports.PlaceWindow:
796+ s.x11Place(c)
777797 case ports.ConfigureWindow:
778798 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
799+ if w == nil || !w.toplevelAlive() || !w.alive() {
780800 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781801 return
782802 }
@@ -787,16 +807,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787807 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788808 w.last, w.hasLast = c, true
789809 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)
810+ s.relocateCallbacks(w.surf())
791811 s.dropFeedbacks(time.Now())
792812 s.wakePacer() // reconsider FIFO barriers at the new period
793813 }
794814 w.sendConfigure()
795815 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)
816+ if scanoutChanged && w.surf() != nil {
817+ s.dmabuf.resendSurface(w.surf())
798818 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {
819+ if surf := w.surf(); surf != nil && c.Output != "" {
800820 surf.sendTreeScale()
801821 }
802822 if scanoutChanged {
@@ -817,10 +837,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817837 s.setShortcutsInhibit(c)
818838 case ports.CloseWindow:
819839 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
840+ if w == nil || !w.toplevelAlive() {
821841 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822842 return
823843 }
824- w.toplevel.SendClose()
844+ w.sendClose()
825845 }
826846 }
M internal/adapters/wayland/surface.go +15 -2
@@ -28,6 +28,9 @@ const (
2828 roleSessionLock
2929 // roleDragIcon is the icon of a data drag (dnd.go).
3030 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ roleX11
3134 )
3235
3336 type surface struct {
@@ -47,8 +50,12 @@ type surface struct {
4750 formatOpaque bool
4851 kind roleKind
4952 xdg *xdgSurface
50- layer *layerSurface
51- lock *lockSurface
53+ // x11win is the X11 window the surface is associated with; x11Serial is
54+ // the serial of xwayland_surface_v1.set_serial, 0 until set.
55+ x11win *window
56+ x11Serial uint64
57+ layer *layerSurface
58+ lock *lockSurface
5259 // bufferCommitted is permanent, including commits later discarded by FIFO.
5360 bufferCommitted bool
5461 server *Server
@@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
192199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
193200 return s.xdg.window.id
194201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
195205 if s.layer != nil && s.layer.mapped {
196206 return s.layer.id
197207 }
@@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
566576 if s.xdg != nil && s.xdg.window != nil {
567577 s.xdg.window.afterCommit()
568578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
569582 if s.lock != nil {
570583 // Role callbacks precede content construction; publish lock placement
571584 // only once validated current dimensions are available.
A internal/adapters/wayland/window.go +112 -0
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14+func (w *window) isToplevel() bool { return w.toplevel != nil || w.x11 != nil }
15+
16+// srv is the server the window belongs to.
17+func (w *window) srv() *Server {
18+ if w.xdg != nil {
19+ return w.xdg.server
20+ }
21+ return w.x11.server
22+}
23+
24+// surf is the window's surface, nil when it has none.
25+func (w *window) surf() *surface {
26+ switch {
27+ case w.xdg != nil:
28+ return w.xdg.surface
29+ case w.x11 != nil:
30+ return w.x11.surface
31+ }
32+ return nil
33+}
34+
35+// alive reports whether the window's role object is alive.
36+func (w *window) alive() bool {
37+ switch {
38+ case w.xdg != nil:
39+ return w.xdg.resource.Resource.Alive()
40+ case w.x11 != nil:
41+ return w.x11.surface != nil && w.x11.surface.wl.Resource.Alive()
42+ }
43+ return false
44+}
45+
46+// client is the window's client; the zero Client when it has no role object.
47+func (w *window) client() server.Client {
48+ switch {
49+ case w.xdg != nil:
50+ return w.xdg.resource.Client()
51+ case w.x11 != nil && w.x11.surface != nil:
52+ return w.x11.surface.wl.Client()
53+ }
54+ return server.Client{}
55+}
56+
57+// geometry is the committed window geometry; empty means the whole surface.
58+func (w *window) geometry() ports.Rect {
59+ if w.xdg != nil {
60+ return w.xdg.geometry
61+ }
62+ return ports.Rect{}
63+}
64+
65+// sendConfigure sends w.last to the client.
66+func (w *window) sendConfigure() {
67+ switch {
68+ case w.xdg != nil:
69+ w.sendXDGConfigure()
70+ case w.x11 != nil:
71+ w.x11.server.x11Configure(w)
72+ }
73+}
74+
75+// sendClose asks the client to close the window.
76+func (w *window) sendClose() {
77+ switch {
78+ case w.toplevel != nil:
79+ w.toplevel.SendClose()
80+ case w.x11 != nil:
81+ w.x11.server.x11Close(w)
82+ }
83+}
84+
85+// toplevelAlive reports whether the window is a toplevel whose protocol
86+// object is alive.
87+func (w *window) toplevelAlive() bool {
88+ if w.x11 != nil {
89+ return w.alive()
90+ }
91+ return w.toplevel != nil && w.toplevel.Resource.Alive()
92+}
93+
94+// surfaceResource is the window's wl_surface, nil when it has none or it died.
95+func (w *window) surfaceResource() *wayland.Surface {
96+ switch {
97+ case w.xdg != nil:
98+ return w.xdg.surfaceResource()
99+ case w.x11 != nil && w.alive():
100+ return w.x11.surface.wl
101+ }
102+ return nil
103+}
104+
105+// toplevelWindow is the toplevel window (xdg or X11) the surface is the role
106+// surface of, nil for other roles and popups' parents are not followed.
107+func (s *surface) toplevelWindow() *window {
108+ if s.xdg != nil && s.xdg.window != nil {
109+ return s.xdg.window
110+ }
111+ return s.x11win
112+}
M internal/adapters/wayland/wire_client_test.go +3 -0
@@ -55,6 +55,7 @@ import (
5555 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5656 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5757 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
58+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
5859 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
5960 "github.com/bnema/purego-libwayland/server"
6061 "github.com/bnema/wlturbo"
@@ -253,6 +254,8 @@ var wireSchemas = map[string]wireSchema{
253254 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254255 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255256 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
257+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
258+ "xwayland_surface_v1": {wire_xwaylandshell.XwaylandSurfaceV1Interface, nil, map[uint16]bool{1: true}, nil},
256259 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257260 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258261 }
A internal/adapters/wayland/x11peer_test.go +298 -0
@@ -0,0 +1,298 @@
1+package wayland
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "os"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// x11Peer is a scripted X server speaking the real wire protocol over a
15+// socketpair: setup, extensions, atoms, properties, geometry. It records every
16+// request. It is a protocol peer, not a double of a project interface.
17+type x11Peer struct {
18+ t *testing.T
19+ f *os.File
20+ mu sync.Mutex
21+
22+ seq uint16
23+ reqs []x11Req
24+ atoms map[string]uint32
25+ nextAtom uint32
26+ props map[uint32]map[uint32][]byte // window -> atom -> typed data (type, format, data)
27+ propMeta map[uint32]map[uint32][2]uint32
28+ done chan struct{}
29+}
30+
31+type x11Req struct {
32+ major, minor uint8
33+ raw []byte
34+}
35+
36+func (r x11Req) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
37+
38+const (
39+ x11PeerRoot = 0x100
40+ x11Win = 0x500001
41+ // Opcodes the tests look for.
42+ x11OpConfigureWindow = 12
43+ x11OpSetInputFocus = 42
44+ x11OpKillClient = 113
45+)
46+
47+func startX11Peer(t *testing.T) (int, *x11Peer) {
48+ t.Helper()
49+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ p := &x11Peer{
54+ t: t, f: os.NewFile(uintptr(fds[1]), "x11peer"), atoms: map[string]uint32{}, nextAtom: 1000,
55+ props: map[uint32]map[uint32][]byte{}, propMeta: map[uint32]map[uint32][2]uint32{}, done: make(chan struct{}),
56+ }
57+ t.Cleanup(func() { p.f.Close(); <-p.done })
58+ go p.serve()
59+ return fds[0], p
60+}
61+
62+func (p *x11Peer) setupReply() []byte {
63+ vendor := []byte("x11pr\x00\x00\x00")
64+ b := make([]byte, 40+len(vendor)+40)
65+ b[0], b[2] = 1, 11
66+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
67+ binary.LittleEndian.PutUint32(b[12:], 0x200000)
68+ binary.LittleEndian.PutUint32(b[16:], 0x1fffff)
69+ binary.LittleEndian.PutUint16(b[24:], 5)
70+ b[28] = 1
71+ copy(b[40:], vendor)
72+ scr := b[40+len(vendor):]
73+ binary.LittleEndian.PutUint32(scr, x11PeerRoot)
74+ binary.LittleEndian.PutUint32(scr[32:], 0x22)
75+ scr[38] = 24
76+ return b
77+}
78+
79+func (p *x11Peer) serve() {
80+ defer close(p.done)
81+ var hdr [12]byte
82+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
83+ return
84+ }
85+ if _, err := p.f.Write(p.setupReply()); err != nil {
86+ return
87+ }
88+ for {
89+ var h [4]byte
90+ if _, err := io.ReadFull(p.f, h[:]); err != nil {
91+ return
92+ }
93+ raw := h[:]
94+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
95+ if n == 0 {
96+ var l [4]byte
97+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
98+ return
99+ }
100+ raw = append(raw, l[:]...)
101+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
102+ }
103+ rest := make([]byte, n-len(raw))
104+ if _, err := io.ReadFull(p.f, rest); err != nil {
105+ return
106+ }
107+ raw = append(append([]byte(nil), raw...), rest...)
108+ p.mu.Lock()
109+ p.seq++
110+ r := x11Req{h[0], h[1], raw}
111+ p.reqs = append(p.reqs, r)
112+ p.answer(r, raw[len(raw)-len(rest):])
113+ p.mu.Unlock()
114+ }
115+}
116+
117+func (p *x11Peer) reply(data uint8, body []byte) {
118+ words := (max(len(body), 24) - 24 + 3) / 4
119+ b := make([]byte, 32+4*words)
120+ b[0], b[1] = 1, data
121+ binary.LittleEndian.PutUint16(b[2:], p.seq)
122+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
123+ copy(b[8:], body)
124+ _, _ = p.f.Write(b)
125+}
126+
127+func x11le32(v ...uint32) []byte {
128+ b := make([]byte, 0, 4*len(v))
129+ for _, x := range v {
130+ b = binary.LittleEndian.AppendUint32(b, x)
131+ }
132+ return b
133+}
134+
135+var x11Exts = map[string]uint8{"BIG-REQUESTS": 133, "XC-MISC": 136, "Composite": 142}
136+
137+func (p *x11Peer) answer(r x11Req, body []byte) {
138+ switch {
139+ case r.major == 98: // QueryExtension
140+ major, ok := x11Exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
141+ present := uint8(0)
142+ if ok {
143+ present = 1
144+ }
145+ p.reply(0, []byte{present, major, 0, 0})
146+ case r.major == 16: // InternAtom
147+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
148+ a, ok := p.atoms[name]
149+ if !ok {
150+ p.nextAtom++
151+ a = p.nextAtom
152+ p.atoms[name] = a
153+ }
154+ p.reply(0, x11le32(a))
155+ case r.major == 43: // GetInputFocus (Sync)
156+ p.reply(1, x11le32(0))
157+ case r.major == 15: // QueryTree
158+ b := make([]byte, 24)
159+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
160+ p.reply(0, b)
161+ case r.major == 14: // GetGeometry
162+ b := make([]byte, 24)
163+ binary.LittleEndian.PutUint32(b, x11PeerRoot)
164+ binary.LittleEndian.PutUint16(b[8:], 300)
165+ binary.LittleEndian.PutUint16(b[10:], 200)
166+ p.reply(24, b)
167+ case r.major == 18: // ChangeProperty
168+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
169+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
170+ p.store(win, prop, binary.LittleEndian.Uint32(body[8:]), uint32(format), body[20:20+n*int(format)/8])
171+ case r.major == 20: // GetProperty
172+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
173+ data, ok := p.props[win][prop]
174+ b := make([]byte, 24+(len(data)+3)&^3)
175+ var format uint8
176+ if ok {
177+ meta := p.propMeta[win][prop]
178+ format = uint8(meta[1])
179+ binary.LittleEndian.PutUint32(b[0:], meta[0])
180+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(format)/8)))
181+ copy(b[24:], data)
182+ }
183+ p.reply(format, b)
184+ case r.major >= 128:
185+ switch r.major {
186+ case 133:
187+ p.reply(0, x11le32(0x100000))
188+ case 136:
189+ if r.minor == 1 {
190+ p.reply(0, x11le32(0, 1))
191+ }
192+ case 142:
193+ if r.minor == 0 {
194+ p.reply(0, x11le32(0, 4))
195+ }
196+ }
197+ }
198+}
199+
200+func (p *x11Peer) store(win, prop, typ, format uint32, data []byte) {
201+ if p.props[win] == nil {
202+ p.props[win], p.propMeta[win] = map[uint32][]byte{}, map[uint32][2]uint32{}
203+ }
204+ p.props[win][prop] = append([]byte(nil), data...)
205+ p.propMeta[win][prop] = [2]uint32{typ, format}
206+}
207+
208+// atom interns name the way the XWM would have.
209+func (p *x11Peer) atom(name string) uint32 {
210+ p.mu.Lock()
211+ defer p.mu.Unlock()
212+ a, ok := p.atoms[name]
213+ if !ok {
214+ p.nextAtom++
215+ a = p.nextAtom
216+ p.atoms[name] = a
217+ }
218+ return a
219+}
220+
221+// setProp scripts a window property (typ is an atom number, format 8 or 32).
222+func (p *x11Peer) setProp(win, prop, typ, format uint32, data []byte) {
223+ p.mu.Lock()
224+ defer p.mu.Unlock()
225+ p.store(win, prop, typ, format, data)
226+}
227+
228+func (p *x11Peer) event(code uint8, fill func(b *[32]byte)) {
229+ p.mu.Lock()
230+ defer p.mu.Unlock()
231+ var b [32]byte
232+ b[0] = code
233+ binary.LittleEndian.PutUint16(b[2:], p.seq)
234+ fill(&b)
235+ _, _ = p.f.Write(b[:])
236+}
237+
238+func (p *x11Peer) createNotify(win uint32) {
239+ p.event(16, func(b *[32]byte) {
240+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
241+ binary.LittleEndian.PutUint32(b[8:], win)
242+ binary.LittleEndian.PutUint16(b[16:], 100)
243+ binary.LittleEndian.PutUint16(b[18:], 50)
244+ })
245+}
246+
247+func (p *x11Peer) windowEvent(code uint8, win uint32) {
248+ p.event(code, func(b *[32]byte) {
249+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
250+ binary.LittleEndian.PutUint32(b[8:], win)
251+ })
252+}
253+
254+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
255+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
256+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
257+
258+func (p *x11Peer) propertyNotify(win, atom uint32) {
259+ p.event(28, func(b *[32]byte) {
260+ binary.LittleEndian.PutUint32(b[4:], win)
261+ binary.LittleEndian.PutUint32(b[8:], atom)
262+ })
263+}
264+
265+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
266+ t := p.atom(typ)
267+ p.event(33, func(b *[32]byte) {
268+ b[1] = 32
269+ binary.LittleEndian.PutUint32(b[4:], win)
270+ binary.LittleEndian.PutUint32(b[8:], t)
271+ for i, d := range data {
272+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
273+ }
274+ })
275+}
276+
277+func (p *x11Peer) requests() []x11Req {
278+ p.mu.Lock()
279+ defer p.mu.Unlock()
280+ return append([]x11Req(nil), p.reqs...)
281+}
282+
283+// waitReq waits for a request after the first skip ones that satisfies match.
284+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
285+ p.t.Helper()
286+ deadline := time.Now().Add(5 * time.Second)
287+ for time.Now().Before(deadline) {
288+ reqs := p.requests()
289+ for i := skip; i < len(reqs); i++ {
290+ if match(reqs[i]) {
291+ return reqs[i], i + 1
292+ }
293+ }
294+ time.Sleep(time.Millisecond)
295+ }
296+ p.t.Fatal("x11 peer: expected request never arrived")
297+ return x11Req{}, 0
298+}
A internal/adapters/wayland/x11window.go +307 -0
@@ -0,0 +1,307 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
6+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
12+// the wl_surface Xwayland paired with it. Display goroutine only.
13+type x11Window struct {
14+ server *Server
15+ slot xwm.Slot
16+ xid uint32
17+ surface *surface
18+ info xwm.WindowInfo
19+ // ready is set once the XWM read the window's properties (Ready).
20+ ready bool
21+}
22+
23+// xwaylandRestricted are the globals only the Xwayland client may see.
24+func xwaylandRestricted(iface string) bool {
25+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
26+}
27+
28+// globalVisible is the display's global filter: the Xwayland-only globals
29+// are visible to, and bindable by, the Xwayland client alone.
30+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
31+ if xwaylandRestricted(g.Interface()) {
32+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
33+ }
34+ return true
35+}
36+
37+func registerXwaylandShell(d *server.Display, s *Server) error {
38+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
39+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
40+ })
41+}
42+
43+type xwaylandShell struct{ s *Server }
44+
45+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
46+
47+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
48+ surf := h.s.surfaceOf(w)
49+ if surf == nil {
50+ return
51+ }
52+ if surf.kind != roleNone {
53+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
54+ return
55+ }
56+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
57+ return
58+ }
59+ surf.kind = roleX11
60+}
61+
62+type xwaylandSurface struct {
63+ s *Server
64+ surf *surface
65+}
66+
67+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
68+ serial := uint64(hi)<<32 | uint64(lo)
69+ switch {
70+ case x.surf.x11Serial != 0:
71+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
72+ case serial == 0:
73+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
74+ default:
75+ x.surf.x11Serial = serial
76+ if x.s.xwm != nil {
77+ x.s.xwm.SetSerial(x.surf.identity, serial)
78+ }
79+ }
80+}
81+
82+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
83+
84+// installXWM makes x the server's window manager. Display goroutine only.
85+func (s *Server) installXWM(x *xwm.XWM) {
86+ s.teardownXWM()
87+ s.xwm = x
88+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
89+ s.log.Warn().Err(err).Msg("X11 window management disabled")
90+ s.xwm = nil
91+ x.CloseConn()
92+ return
93+ }
94+ // Serials sent before the XWM was attached pair now.
95+ for _, surf := range s.surfaces {
96+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
97+ x.SetSerial(surf.identity, surf.x11Serial)
98+ }
99+ }
100+}
101+
102+// teardownXWM closes the window manager, dropping its windows. Idempotent.
103+func (s *Server) teardownXWM() {
104+ if x := s.xwm; x != nil {
105+ s.xwm = nil
106+ x.Close()
107+ }
108+}
109+
110+// x11Configure forwards the latest core configure of an X11 window.
111+func (s *Server) x11Configure(w *window) {
112+ if s.xwm != nil {
113+ s.xwm.Configure(w.x11.slot, w.last)
114+ }
115+}
116+
117+// x11Close asks an X11 window to close.
118+func (s *Server) x11Close(w *window) {
119+ if s.xwm != nil {
120+ s.xwm.CloseWindow(w.x11.slot)
121+ }
122+}
123+
124+// x11Place forwards core's position of an X11 window's client rect.
125+func (s *Server) x11Place(c ports.PlaceWindow) {
126+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
127+ s.xwm.Place(w.x11.slot, c.X, c.Y)
128+ }
129+}
130+
131+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
132+// native window does.
133+func (s *Server) x11Focus() {
134+ if s.xwm == nil {
135+ return
136+ }
137+ var slot xwm.Slot
138+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
139+ slot = w.x11.slot
140+ }
141+ s.xwm.Focus(slot)
142+}
143+
144+// hostImpl is the server as the XWM sees it.
145+type hostImpl struct{ s *Server }
146+
147+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
148+
149+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
150+ s := h.s
151+ w := &window{id: s.nextWindow}
152+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
153+ s.nextWindow++
154+ s.windows[w.id] = w
155+ s.x11Slots[slot] = w
156+ return w.id
157+}
158+
159+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
160+ w := h.window(slot)
161+ if w == nil {
162+ return
163+ }
164+ w.x11.info, w.x11.ready = info, true
165+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
166+ w.parent = h.window(info.Parent)
167+ // A surface associated and committed earlier maps now.
168+ w.x11.tryMap()
169+}
170+
171+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
172+ w := h.window(slot)
173+ surf := h.s.surfaceByIdentity[surfaceID]
174+ if w == nil || surf == nil || surf.kind != roleX11 {
175+ return
176+ }
177+ x := w.x11
178+ if x.surface != nil && x.surface != surf {
179+ // A new surface replaces the shown one: core sees a remap.
180+ w.unmap()
181+ }
182+ x.drop()
183+ x.surface, surf.x11win = surf, w
184+ surf.role = func(buffer bool) {
185+ switch {
186+ case surf.destroyed:
187+ w.unmap()
188+ if x.surface == surf {
189+ x.surface = nil
190+ }
191+ surf.x11win = nil
192+ case buffer:
193+ x.tryMap()
194+ case w.mapped:
195+ w.unmap()
196+ }
197+ }
198+ // The buffer may have been committed before the pairing.
199+ x.tryMap()
200+}
201+
202+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
203+ w := h.window(slot)
204+ if w == nil {
205+ return
206+ }
207+ w.unmap()
208+ w.x11.drop()
209+ w.x11.ready = false
210+ if destroyed {
211+ for _, c := range h.s.windows {
212+ if c.parent == w {
213+ c.parent = nil
214+ }
215+ }
216+ delete(h.s.windows, w.id)
217+ delete(h.s.x11Slots, slot)
218+ h.s.foreignToplevelGone(w)
219+ }
220+}
221+
222+func (h hostImpl) Title(slot xwm.Slot, title string) {
223+ if w := h.window(slot); w != nil && w.title != title {
224+ w.title = title
225+ h.s.toplevelChanged(w)
226+ if w.mapped {
227+ h.s.castInventoryChanged()
228+ }
229+ }
230+}
231+
232+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
233+ w := h.window(slot)
234+ if w == nil || w.appID == appID {
235+ return
236+ }
237+ w.appID = appID
238+ if w.mapped {
239+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
240+ h.s.toplevelChanged(w)
241+ h.s.castInventoryChanged()
242+ }
243+}
244+
245+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
246+ if w := h.window(slot); w != nil {
247+ w.setParent(h.window(parent))
248+ }
249+}
250+
251+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
252+ w := h.window(slot)
253+ if w == nil {
254+ return
255+ }
256+ w.x11.info.Fullscreen = on
257+ if w.mapped {
258+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
259+ }
260+}
261+
262+// drop unlinks the window from its surface.
263+func (x *x11Window) drop() {
264+ if surf := x.surface; surf != nil {
265+ surf.x11win, surf.role = nil, nil
266+ x.surface = nil
267+ }
268+}
269+
270+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
271+func (x *x11Window) tryMap() {
272+ surf := x.surface
273+ if surf == nil {
274+ return
275+ }
276+ w := surf.x11win
277+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
278+ return
279+ }
280+ s := x.server
281+ w.mapped = true
282+ w.gen++
283+ w.floating = x.info.Floating
284+ w.floatW, w.floatH = 0, 0
285+ if w.floating {
286+ w.floatW, w.floatH = x.info.Width, x.info.Height
287+ }
288+ var parent ports.WindowID
289+ if w.parent != nil && w.parent.mapped {
290+ parent = w.parent.id
291+ }
292+ s.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, PID: x.info.PID, Floating: w.floating, Width: w.floatW, Height: w.floatH, Parent: parent, Positioned: true, Modal: w.modal, Remap: w.gen > 1})
293+ if x.info.Fullscreen {
294+ s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true})
295+ }
296+ s.syncInhibitors()
297+ s.toplevelChanged(w)
298+ s.castInventoryChanged()
299+ s.log.Info().Uint64("id", uint64(w.id)).Uint32("xid", x.xid).Str("app_id", w.appID).Bool("floating", w.floating).Msg("x11 window mapped")
300+ // Called from a commit, the content follows right after; called from the
301+ // pairing, the surface already shows a buffer and is published now.
302+ if surf.has {
303+ surf.resetInputEmission()
304+ surf.redraw()
305+ surf.emitInput()
306+ }
307+}
A internal/adapters/wayland/x11window_test.go +353 -0
@@ -0,0 +1,353 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "net"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/go-wayland-bindings/server/pointerconstraints"
12+ "github.com/bnema/go-wayland-bindings/server/wayland"
13+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
14+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
15+ "github.com/bnema/neferwl/internal/ports"
16+ "github.com/bnema/wlturbo"
17+ "golang.org/x/sys/unix"
18+)
19+
20+var x11Serial = uint64(0x1234_0000_0042)
21+
22+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
23+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
24+// between them.
25+type x11Rig struct {
26+ t *testing.T
27+ s *Server
28+ events chan ports.ClientEvent
29+ commands chan ports.ClientCommand
30+ dir string
31+ xc *wlturbo.Display
32+ peer *x11Peer
33+ stop func()
34+ shell uint32
35+ comp uint32
36+}
37+
38+func newX11Rig(t *testing.T) *x11Rig {
39+ t.Helper()
40+ s, events, commands, dir := lifecycleServer(t)
41+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
42+ wl := xwaylandPair(t)
43+ s.AttachXwayland(wl[0])
44+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
45+ wl[1] = -1
46+ conn, err := net.FileConn(f)
47+ f.Close()
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
52+ t.Fatal(err)
53+ }
54+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
55+ if err := r.xc.Roundtrip(); err != nil {
56+ t.Fatal(err)
57+ }
58+ wm, peer := startX11Peer(t)
59+ r.peer = peer
60+ r.stop = s.AttachXWM(context.Background(), wm)
61+ t.Cleanup(func() { r.stop() })
62+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
63+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
64+ return r
65+}
66+
67+// x11Window announces and maps X window win with the properties of an
68+// ordinary client, and waits for the XWM to be ready for it.
69+func (r *x11Rig) x11Window(win uint32) {
70+ r.t.Helper()
71+ p := r.peer
72+ str := uint32(31) // XA_STRING
73+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
74+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
75+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
76+ p.createNotify(win)
77+ skip := len(p.requests())
78+ p.mapRequest(win)
79+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
80+}
81+
82+// pair sends the serial from both sides and commits a buffer on the surface.
83+// It returns the wl_surface id of the Xwayland client.
84+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
85+ r.t.Helper()
86+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
87+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
88+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
89+ registerProtocol(r.t, r.xc, surf)
90+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
91+ registerProtocol(r.t, r.xc, xs)
92+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
93+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
94+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
95+ if err := r.xc.Roundtrip(); err != nil {
96+ r.t.Fatal(err)
97+ }
98+ return surf
99+}
100+
101+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
102+ t.Helper()
103+ for deadline := time.After(3 * time.Second); ; {
104+ select {
105+ case ev := <-events:
106+ if v, ok := ev.(T); ok {
107+ return v
108+ }
109+ case <-deadline:
110+ var zero T
111+ t.Fatalf("no %T event", zero)
112+ }
113+ }
114+}
115+
116+func TestXwaylandGlobalFilter(t *testing.T) {
117+ s, _, _, dir := lifecycleServer(t)
118+ other := protocolClient(t, s, dir)
119+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
120+ if _, ok := other.Registry().FindGlobal(iface); ok {
121+ t.Errorf("ordinary client sees %s", iface)
122+ }
123+ }
124+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
125+ t.Error("ordinary globals filtered out")
126+ }
127+ r := newX11Rig(t)
128+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
129+ t.Error("Xwayland client misses xwayland_shell_v1")
130+ }
131+ // The ordinary client of the same server still does not see it.
132+ late := protocolClient(t, r.s, r.dir)
133+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
134+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
135+ }
136+}
137+
138+func TestXwaylandShellRoleErrors(t *testing.T) {
139+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
140+ t.Run(scenario, func(t *testing.T) {
141+ r := newX11Rig(t)
142+ c := r.xc
143+ wmBase := bindProtocol(t, c, "xdg_wm_base")
144+ surf := c.AllocateID()
145+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
146+ registerProtocol(t, c, surf)
147+ xs := c.AllocateID()
148+ switch scenario {
149+ case "xdg role first":
150+ xdg := xs
151+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
152+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
153+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
154+ case "serial twice":
155+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
156+ registerProtocol(t, c, xs)
157+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
158+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
159+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
160+ case "zero serial":
161+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
162+ registerProtocol(t, c, xs)
163+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
164+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
165+ }
166+ })
167+ }
168+}
169+
170+// A window announced by the X server and a surface carrying the same serial
171+// map as one neferwl window with the X11 info; core commands reach the X side.
172+func TestX11WindowLifecycle(t *testing.T) {
173+ r := newX11Rig(t)
174+ win := uint32(x11Win)
175+ r.x11Window(win)
176+ surf := r.pair(win, x11Serial)
177+
178+ m := awaitEvent[ports.WindowMapped](t, r.events)
179+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
180+ t.Fatalf("mapped %+v", m)
181+ }
182+
183+ // Title and app id changes.
184+ p := r.peer
185+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
186+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
187+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
188+ p.propertyNotify(win, 67)
189+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
190+ t.Fatalf("app id %+v", a)
191+ }
192+ var title string
193+ waitTrue(t, "title", func() bool {
194+ r.s.display.Do(func() {
195+ if w := r.s.windows[m.ID]; w != nil {
196+ title = w.title
197+ }
198+ })
199+ return title == "renamed"
200+ })
201+
202+ // Fullscreen request from the client.
203+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
204+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
205+ t.Fatalf("fullscreen %+v", f)
206+ }
207+
208+ // Configure and Place reach the X server as ConfigureWindow.
209+ skip := len(p.requests())
210+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
211+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
212+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
213+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
214+ }
215+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
216+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
217+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
218+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
219+ }
220+
221+ // Focus.
222+ skip = len(p.requests())
223+ r.commands <- ports.FocusWindow{ID: m.ID}
224+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
225+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
226+ }
227+
228+ // Close: without WM_DELETE_WINDOW the client is killed.
229+ skip = len(p.requests())
230+ r.commands <- ports.CloseWindow{ID: m.ID}
231+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
232+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
233+ }
234+
235+ // Destroying the surface unmaps the window.
236+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
237+ if err := r.xc.Roundtrip(); err != nil {
238+ t.Fatal(err)
239+ }
240+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
241+ t.Fatalf("unmapped %+v", u)
242+ }
243+}
244+
245+// The serial may arrive on the Wayland side before the X side knows it, and
246+// an UnmapNotify unmaps; a remap pairs again.
247+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
248+ r := newX11Rig(t)
249+ win := uint32(x11Win)
250+ p := r.peer
251+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
252+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
253+ registerProtocol(t, r.xc, surf)
254+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
255+ registerProtocol(t, r.xc, xs)
256+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
257+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
258+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
259+ if err := r.xc.Roundtrip(); err != nil {
260+ t.Fatal(err)
261+ }
262+ r.x11Window(win)
263+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
264+ m := awaitEvent[ports.WindowMapped](t, r.events)
265+ if m.Remap {
266+ t.Fatalf("first map flagged as remap: %+v", m)
267+ }
268+ p.unmapNotify(win)
269+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
270+ t.Fatalf("unmapped %+v", u)
271+ }
272+ p.destroyNotify(win)
273+ waitTrue(t, "window forgotten", func() bool {
274+ gone := false
275+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
276+ return gone
277+ })
278+}
279+
280+// stop closes the XWM: its windows unmap, and the server keeps serving.
281+func TestX11StopUnmapsWindows(t *testing.T) {
282+ r := newX11Rig(t)
283+ win := uint32(x11Win)
284+ r.x11Window(win)
285+ r.pair(win, x11Serial)
286+ m := awaitEvent[ports.WindowMapped](t, r.events)
287+ r.stop()
288+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
289+ t.Fatalf("unmapped %+v", u)
290+ }
291+ r.s.display.Do(func() {
292+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
293+ t.Error("XWM state left behind")
294+ }
295+ })
296+ r.stop() // idempotent
297+}
298+
299+// Boot gives up as soon as its context ends, even against a silent server,
300+// and leaves the server serving.
301+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
302+ s, _, _, dir := lifecycleServer(t)
303+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ defer unix.Close(fds[1]) // the silent server end
308+ ctx, cancel := context.WithCancel(context.Background())
309+ done := make(chan func(), 1)
310+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
311+ select {
312+ case <-done:
313+ t.Fatal("AttachXWM returned before the context ended")
314+ case <-time.After(150 * time.Millisecond):
315+ }
316+ cancel()
317+ select {
318+ case stop := <-done:
319+ stop()
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("AttachXWM did not abort with its context")
322+ }
323+ assertFDClosed(t, fds[0])
324+ protocolClient(t, s, dir) // still serving
325+}
326+
327+// Xwayland locks the pointer for games: the constraint reaches core for the
328+// X11 window like for an xdg toplevel.
329+func TestX11WindowPointerLock(t *testing.T) {
330+ r := newX11Rig(t)
331+ win := uint32(x11Win)
332+ r.x11Window(win)
333+ surf := r.pair(win, x11Serial)
334+ m := awaitEvent[ports.WindowMapped](t, r.events)
335+
336+ seat := bindProtocol(t, r.xc, "wl_seat")
337+ registerProtocol(t, r.xc, seat)
338+ pointer := r.xc.AllocateID()
339+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
340+ registerProtocol(t, r.xc, pointer)
341+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
342+ lock := r.xc.AllocateID()
343+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
344+ registerProtocol(t, r.xc, lock)
345+ if err := r.xc.Roundtrip(); err != nil {
346+ t.Fatal(err)
347+ }
348+ r.commands <- ports.PointerFocus{ID: m.ID}
349+ r.commands <- ports.FocusWindow{ID: m.ID}
350+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
351+ t.Fatalf("constrained %+v", got)
352+ }
353+}
M internal/adapters/wayland/xdg.go +32 -21
@@ -128,10 +128,12 @@ func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
128128
129129 // window is an xdg_toplevel, or an xdg_popup when popup is set.
130130 type window struct {
131- id ports.WindowID
132- toplevel *xdgshell.Toplevel
133- popup *popup
134- xdg *xdgSurface
131+ id ports.WindowID
132+ toplevel *xdgshell.Toplevel
133+ popup *popup
134+ xdg *xdgSurface
135+ // x11 is set for the window of an X11 client (x11window.go).
136+ x11 *x11Window
135137 appID, title string
136138 mapped bool
137139 // gen counts the mappings: an unmap and a remap is a new toplevel to
@@ -151,8 +153,8 @@ type window struct {
151153 hasLast bool
152154 }
153155
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {
156+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
157+func (w *window) sendXDGConfigure() {
156158 c := w.last
157159 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158160 s := w.xdg.server
@@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
192194 }
193195
194196 func (w *window) unmap() {
195- if w.xdg.server.seat.pointerFocus == w.id {
196- w.xdg.server.changePointerFocus(0, 0, 0)
197+ s := w.srv()
198+ if s.seat.pointerFocus == w.id {
199+ s.changePointerFocus(0, 0, 0)
197200 }
198- if w.xdg.server.seat.focused == w.id {
199- w.xdg.server.changeFocus(0)
201+ if s.seat.focused == w.id {
202+ s.changeFocus(0)
200203 }
201204 if !w.mapped {
202205 return
203206 }
204207 w.mapped = false
205- w.xdg.server.toplevelClosed(w.id)
206- w.xdg.surface.resetInputEmission()
207- w.xdg.configured = false
208- w.xdg.acked = false
209- w.xdg.serials = nil
210- w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
211- w.xdg.server.syncInhibitors()
212- w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
208+ s.toplevelClosed(w.id)
209+ if surf := w.surf(); surf != nil {
210+ surf.resetInputEmission()
211+ }
212+ if x := w.xdg; x != nil {
213+ x.configured = false
214+ x.acked = false
215+ x.serials = nil
216+ }
217+ s.emit(ports.WindowUnmapped{ID: w.id})
218+ s.syncInhibitors()
219+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
213220 }
214221 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215222 if x.window != nil {
@@ -337,7 +344,7 @@ func (w *window) setParent(p *window) {
337344 if p != nil && p.mapped {
338345 id = p.id
339346 }
340- w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
347+ w.srv().emit(ports.WindowParent{ID: w.id, Parent: id})
341348 }
342349 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
343350 if title == t.w.title {
@@ -417,7 +424,7 @@ func (w *window) afterCommit() {
417424 }
418425 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
419426 w.floatW, w.floatH = fw, fh
420- w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
427+ w.srv().emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
421428 }
422429 }
423430
@@ -431,6 +438,10 @@ func (w *window) floats() bool {
431438
432439 // size is the window geometry size, else the surface size, in logical pixels.
433440 func (w *window) size() (int, int) {
441+ if w.xdg == nil {
442+ c := w.surf().content
443+ return c.LogicalW, c.LogicalH
444+ }
434445 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {
435446 return g.W, g.H
436447 }
@@ -441,7 +452,7 @@ func (w *window) size() (int, int) {
441452 // surfacePoint turns window coordinates (core's, from the geometry origin)
442453 // into surface coordinates.
443454 func (w *window) surfacePoint(x, y float64) (float64, float64) {
444- g := w.xdg.geometry
455+ g := w.geometry()
445456 return x + float64(g.X), y + float64(g.Y)
446457 }
447458
A internal/adapters/wayland/xwayland.go +75 -0
@@ -0,0 +1,75 @@
1+package wayland
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/neferwl/internal/adapters/logging"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/purego-libwayland/server"
9+ "golang.org/x/sys/unix"
10+)
11+
12+// xwaylandClient belongs to the display goroutine.
13+type xwaylandClient struct {
14+ client server.Client
15+}
16+
17+// AttachXwayland transfers ownership of the Wayland descriptor to the display.
18+// It may be called from any goroutine.
19+func (s *Server) AttachXwayland(wlFD int) {
20+ ran := s.display.Do(func() {
21+ c, err := s.display.CreateClient(wlFD)
22+ if err != nil {
23+ s.log.Warn().Err(err).Msg("Xwayland connection not adopted")
24+ _ = unix.Close(wlFD) // CreateClient only adopts on success.
25+ return
26+ }
27+ s.forgetXwayland()
28+ s.xwayland = xwaylandClient{client: c}
29+ c.OnDestroy(func() {
30+ if s.xwayland.client == c {
31+ s.teardownXWM()
32+ s.forgetXwayland()
33+ }
34+ })
35+ s.log.Info().Msg("Xwayland attached")
36+ })
37+ if !ran {
38+ _ = unix.Close(wlFD)
39+ }
40+}
41+
42+// forgetXwayland runs on the display owner, or after Run has joined it.
43+func (s *Server) forgetXwayland() {
44+ if s.xwayland.client != (server.Client{}) {
45+ s.xwayland = xwaylandClient{}
46+ }
47+}
48+
49+// AttachXWM bootstraps the X11 window manager on the window manager
50+// descriptor, which it owns. It runs on the starter goroutine once Xwayland is
51+// ready and blocks until the XWM is installed, ctx ends or Boot fails. stop
52+// closes the XWM on the display goroutine and waits for it.
53+func (s *Server) AttachXWM(ctx context.Context, wmFD int) (stop func()) {
54+ log := logging.For(ctx, "xwm")
55+ x, err := xwm.Boot(ctx, wmFD, log)
56+ if err != nil {
57+ if ctx.Err() != nil {
58+ log.Info().Err(err).Msg("X11 window management not started: Xwayland is going away")
59+ } else {
60+ log.Warn().Err(err).Msg("X11 window management disabled")
61+ }
62+ return func() {}
63+ }
64+ if !s.display.Do(func() { s.installXWM(x) }) {
65+ x.CloseConn()
66+ return func() {}
67+ }
68+ return func() {
69+ s.display.Do(func() {
70+ if s.xwm == x {
71+ s.teardownXWM()
72+ }
73+ })
74+ }
75+}
A internal/adapters/wayland/xwayland_test.go +84 -0
@@ -0,0 +1,84 @@
1+package wayland
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/purego-libwayland/server"
8+ "golang.org/x/sys/unix"
9+)
10+
11+func xwaylandPair(t *testing.T) *[2]int {
12+ t.Helper()
13+ p, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ t.Cleanup(func() {
18+ if p[1] >= 0 {
19+ unix.Close(p[1])
20+ }
21+ })
22+ return &p
23+}
24+
25+func assertFDClosed(t *testing.T, fd int) {
26+ t.Helper()
27+ if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
28+ t.Fatalf("fd %d still open: %v", fd, err)
29+ }
30+}
31+
32+func TestAttachXwaylandLifecycle(t *testing.T) {
33+ h := newAdmissionHarness(t, 1)
34+ wl := xwaylandPair(t)
35+ h.s.AttachXwayland(wl[0])
36+ var first server.Client
37+ h.s.display.Do(func() { first = h.s.xwayland.client })
38+ if first == (server.Client{}) {
39+ t.Fatal("client not recorded")
40+ }
41+ wl2 := xwaylandPair(t)
42+ h.s.AttachXwayland(wl2[0])
43+ unix.Close(wl[1])
44+ wl[1] = -1 // Old client's destroy must not clear the new record.
45+ waitTrue(t, "old client destroyed", func() bool {
46+ _, err := unix.FcntlInt(uintptr(wl[0]), unix.F_GETFD, 0)
47+ return errors.Is(err, unix.EBADF)
48+ })
49+ h.s.display.Do(func() {
50+ if h.s.xwayland.client == (server.Client{}) || h.s.xwayland.client == first {
51+ t.Error("replacement lost")
52+ }
53+ })
54+ unix.Close(wl2[1])
55+ wl2[1] = -1
56+ waitTrue(t, "Xwayland disconnected", func() bool {
57+ clear := false
58+ h.s.display.Do(func() { clear = h.s.xwayland.client == (server.Client{}) })
59+ return clear
60+ })
61+}
62+
63+func TestAttachXwaylandFailureClosesFDs(t *testing.T) {
64+ h := newAdmissionHarness(t, 1)
65+ var p [2]int
66+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
67+ t.Fatal(err)
68+ }
69+ defer unix.Close(p[1])
70+ h.s.AttachXwayland(p[0])
71+ assertFDClosed(t, p[0])
72+}
73+
74+func TestAttachXwaylandStopped(t *testing.T) {
75+ d, err := server.NewDisplay()
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ d.Close()
80+ s := &Server{display: d}
81+ wl := xwaylandPair(t)
82+ s.AttachXwayland(wl[0])
83+ assertFDClosed(t, wl[0])
84+}
A internal/adapters/wayland/xwm/atoms.go +96 -0
@@ -0,0 +1,96 @@
1+package xwm
2+
3+// Atoms interned at boot in one round trip, in this fixed order. The table
4+// includes the clipboard and selection atoms.
5+const (
6+ atomWMProtocols = iota
7+ atomWMDeleteWindow
8+ atomWMTakeFocus
9+ atomWMState
10+ atomWMChangeState
11+ atomWMS0
12+ atomUTF8String
13+ atomNetWMCMS0
14+ atomNetSupported
15+ atomNetSupportingWMCheck
16+ atomNetWMName
17+ atomNetWMPID
18+ atomNetWMWindowType
19+ atomNetWMWindowTypeNormal
20+ atomNetWMWindowTypeDialog
21+ atomNetWMWindowTypeUtility
22+ atomNetWMWindowTypeSplash
23+ atomNetWMWindowTypeToolbar
24+ atomNetWMWindowTypeMenu
25+ atomNetWMWindowTypeDropdownMenu
26+ atomNetWMWindowTypePopupMenu
27+ atomNetWMWindowTypeTooltip
28+ atomNetWMWindowTypeNotification
29+ atomNetWMWindowTypeCombo
30+ atomNetWMWindowTypeDND
31+ atomNetWMState
32+ atomNetWMStateModal
33+ atomNetWMStateFullscreen
34+ atomNetWMStateFocused
35+ atomNetWMStateMaximizedVert
36+ atomNetWMStateMaximizedHorz
37+ atomNetActiveWindow
38+ atomNetClientList
39+ atomNetClientListStacking
40+ atomNetWMMoveresize
41+ atomNetWMBypassCompositor
42+ atomWLSurfaceID
43+ atomWLSurfaceSerial
44+ atomNetWMIcon
45+ atomMotifWMHints
46+ atomClipboard
47+ atomPrimary
48+ atomTargets
49+ atomCount
50+)
51+
52+var atomNames = [atomCount]string{
53+ atomWMProtocols: "WM_PROTOCOLS",
54+ atomWMDeleteWindow: "WM_DELETE_WINDOW",
55+ atomWMTakeFocus: "WM_TAKE_FOCUS",
56+ atomWMState: "WM_STATE",
57+ atomWMChangeState: "WM_CHANGE_STATE",
58+ atomWMS0: "WM_S0",
59+ atomUTF8String: "UTF8_STRING",
60+ atomNetWMCMS0: "_NET_WM_CM_S0",
61+ atomNetSupported: "_NET_SUPPORTED",
62+ atomNetSupportingWMCheck: "_NET_SUPPORTING_WM_CHECK",
63+ atomNetWMName: "_NET_WM_NAME",
64+ atomNetWMPID: "_NET_WM_PID",
65+ atomNetWMWindowType: "_NET_WM_WINDOW_TYPE",
66+ atomNetWMWindowTypeNormal: "_NET_WM_WINDOW_TYPE_NORMAL",
67+ atomNetWMWindowTypeDialog: "_NET_WM_WINDOW_TYPE_DIALOG",
68+ atomNetWMWindowTypeUtility: "_NET_WM_WINDOW_TYPE_UTILITY",
69+ atomNetWMWindowTypeSplash: "_NET_WM_WINDOW_TYPE_SPLASH",
70+ atomNetWMWindowTypeToolbar: "_NET_WM_WINDOW_TYPE_TOOLBAR",
71+ atomNetWMWindowTypeMenu: "_NET_WM_WINDOW_TYPE_MENU",
72+ atomNetWMWindowTypeDropdownMenu: "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU",
73+ atomNetWMWindowTypePopupMenu: "_NET_WM_WINDOW_TYPE_POPUP_MENU",
74+ atomNetWMWindowTypeTooltip: "_NET_WM_WINDOW_TYPE_TOOLTIP",
75+ atomNetWMWindowTypeNotification: "_NET_WM_WINDOW_TYPE_NOTIFICATION",
76+ atomNetWMWindowTypeCombo: "_NET_WM_WINDOW_TYPE_COMBO",
77+ atomNetWMWindowTypeDND: "_NET_WM_WINDOW_TYPE_DND",
78+ atomNetWMState: "_NET_WM_STATE",
79+ atomNetWMStateModal: "_NET_WM_STATE_MODAL",
80+ atomNetWMStateFullscreen: "_NET_WM_STATE_FULLSCREEN",
81+ atomNetWMStateFocused: "_NET_WM_STATE_FOCUSED",
82+ atomNetWMStateMaximizedVert: "_NET_WM_STATE_MAXIMIZED_VERT",
83+ atomNetWMStateMaximizedHorz: "_NET_WM_STATE_MAXIMIZED_HORZ",
84+ atomNetActiveWindow: "_NET_ACTIVE_WINDOW",
85+ atomNetClientList: "_NET_CLIENT_LIST",
86+ atomNetClientListStacking: "_NET_CLIENT_LIST_STACKING",
87+ atomNetWMMoveresize: "_NET_WM_MOVERESIZE",
88+ atomNetWMBypassCompositor: "_NET_WM_BYPASS_COMPOSITOR",
89+ atomWLSurfaceID: "WL_SURFACE_ID",
90+ atomWLSurfaceSerial: "WL_SURFACE_SERIAL",
91+ atomNetWMIcon: "_NET_WM_ICON",
92+ atomMotifWMHints: "_MOTIF_WM_HINTS",
93+ atomClipboard: "CLIPBOARD",
94+ atomPrimary: "PRIMARY",
95+ atomTargets: "TARGETS",
96+}
A internal/adapters/wayland/xwm/configure.go +266 -0
@@ -0,0 +1,266 @@
1+package xwm
2+
3+import (
4+ "math"
5+
6+ "bnema.dev/xturbo"
7+ "bnema.dev/xturbo/xproto"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+// maxClientSize bounds the size a floating window may ask for.
12+const maxClientSize = 16384
13+
14+// _NET_WM_STATE bits tracked in record.stateSent.
15+const (
16+ stateFullscreen uint8 = 1 << iota
17+ stateFocused
18+)
19+
20+// size is the size to send: the requested one, else the last known.
21+func (r *record) size() (w, h uint32) {
22+ w, h = r.pend.w, r.pend.h
23+ if w == 0 {
24+ w = r.w
25+ }
26+ if h == 0 {
27+ h = r.h
28+ }
29+ return w, h
30+}
31+
32+// differs reports whether the pending configure is not what the X server has.
33+func (r *record) differs() bool {
34+ w, h := r.size()
35+ p, s := &r.pend, &r.sent
36+ return p.x != s.x || p.y != s.y || w != s.w || h != s.h ||
37+ p.fullscreen != s.fullscreen || p.activated != s.activated
38+}
39+
40+// markDirty queues s for the next flush when its pending configure differs.
41+func (x *XWM) markDirty(s Slot, r *record) {
42+ if r.pend.dirty || !r.differs() {
43+ return
44+ }
45+ if len(x.dirty) == cap(x.dirty) {
46+ // Stale entries of freed and reused slots filled the queue: send
47+ // what is queued rather than grow it. r stays valid, nothing here
48+ // grows the table.
49+ x.flushConfigures()
50+ }
51+ r.pend.dirty = true
52+ x.dirty = append(x.dirty, s)
53+}
54+
55+// managedRec returns the record of a live managed window.
56+func (x *XWM) managedRec(s Slot) *record {
57+ if x.closed {
58+ return nil
59+ }
60+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged {
61+ return r
62+ }
63+ return nil
64+}
65+
66+// Configure records the size and state core wants for s; sent at flush. A
67+// zero size keeps the current one.
68+func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) {
69+ r := x.managedRec(s)
70+ if r == nil {
71+ return
72+ }
73+ if c.Width > 0 {
74+ r.pend.w = uint32(min(c.Width, maxClientSize))
75+ }
76+ if c.Height > 0 {
77+ r.pend.h = uint32(min(c.Height, maxClientSize))
78+ }
79+ r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated
80+ x.markDirty(s, r)
81+ x.wake()
82+}
83+
84+// Place records the layout position of the client rectangle of s.
85+func (x *XWM) Place(s Slot, px, py int) {
86+ r := x.managedRec(s)
87+ if r == nil {
88+ return
89+ }
90+ r.pend.x, r.pend.y = int32(px), int32(py)
91+ x.markDirty(s, r)
92+ x.wake()
93+}
94+
95+// flushConfigures sends the pending configures, once per display iteration,
96+// then what focus left to do.
97+func (x *XWM) flushConfigures() {
98+ for _, s := range x.dirty {
99+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
100+ x.sendPending(s, r)
101+ }
102+ }
103+ x.dirty = x.dirty[:0]
104+ x.flushFocus()
105+}
106+
107+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
108+// 4.1.5 asks for, since Xwayland may not move the window) and the
109+// _NET_WM_STATE bits that changed.
110+func (x *XWM) sendPending(s Slot, r *record) {
111+ r.pend.dirty = false
112+ p := &r.pend
113+ w, h := r.size()
114+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
115+ var v xproto.ConfigureWindowValues
116+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
117+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
118+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
119+ x.sendConfigureNotify(r)
120+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
121+ }
122+ var bits uint8
123+ if p.fullscreen {
124+ bits |= stateFullscreen
125+ }
126+ if p.activated {
127+ bits |= stateFocused
128+ }
129+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
130+ r.fullscreen = p.fullscreen // ours now: the property is not read back
131+ if bits != r.stateSent {
132+ r.stateSent = bits
133+ x.writeState(r)
134+ }
135+}
136+
137+// writeState rewrites _NET_WM_STATE of r from its bits.
138+func (x *XWM) writeState(r *record) {
139+ var list [3]uint32
140+ n := 0
141+ if r.modal {
142+ list[n] = x.atoms[atomNetWMStateModal]
143+ n++
144+ }
145+ if r.stateSent&stateFullscreen != 0 {
146+ list[n] = x.atoms[atomNetWMStateFullscreen]
147+ n++
148+ }
149+ if r.stateSent&stateFocused != 0 {
150+ list[n] = x.atoms[atomNetWMStateFocused]
151+ n++
152+ }
153+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
154+}
155+
156+// sendConfigureNotify tells the client its geometry as the XWM sees it.
157+func (x *XWM) sendConfigureNotify(r *record) {
158+ var raw [32]byte
159+ d := xproto.ConfigureNotifyEventData{
160+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
161+ X: int16(min(max(r.x, math.MinInt16), math.MaxInt16)), Y: int16(min(max(r.y, math.MinInt16), math.MaxInt16)),
162+ Width: uint16(min(r.w, math.MaxUint16)), Height: uint16(min(r.h, math.MaxUint16)),
163+ }
164+ d.Put(&raw)
165+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
166+}
167+
168+// onConfigureRequest answers a client resizing itself. Tiled windows are told
169+// their current geometry; floating ones get the size they ask for; unmanaged
170+// and unknown windows are configured as requested.
171+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
172+ e := xproto.ConfigureRequestOf(ev)
173+ xid := uint32(e.Window())
174+ mask := e.ValueMask()
175+ s := x.tab.lookup(xid)
176+ r := x.managedRec(s)
177+ if r == nil {
178+ var v xproto.ConfigureWindowValues
179+ if mask&xproto.ConfigWindowX != 0 {
180+ v.SetX(int32(e.X()))
181+ }
182+ if mask&xproto.ConfigWindowY != 0 {
183+ v.SetY(int32(e.Y()))
184+ }
185+ if mask&xproto.ConfigWindowWidth != 0 {
186+ v.SetWidth(uint32(e.Width()))
187+ }
188+ if mask&xproto.ConfigWindowHeight != 0 {
189+ v.SetHeight(uint32(e.Height()))
190+ }
191+ if mask&xproto.ConfigWindowBorderWidth != 0 {
192+ v.SetBorderWidth(uint32(e.BorderWidth()))
193+ }
194+ if mask&xproto.ConfigWindowSibling != 0 {
195+ v.SetSibling(e.Sibling())
196+ }
197+ if mask&xproto.ConfigWindowStackMode != 0 {
198+ v.SetStackMode(e.StackMode())
199+ }
200+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
201+ return
202+ }
203+ if !r.ready {
204+ x.configureBeforeReady(s, r, e)
205+ return
206+ }
207+ if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
208+ w, h := r.w, r.h
209+ if mask&xproto.ConfigWindowWidth != 0 {
210+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
211+ }
212+ if mask&xproto.ConfigWindowHeight != 0 {
213+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
214+ }
215+ if w > 0 && h > 0 {
216+ var v xproto.ConfigureWindowValues
217+ v.SetWidth(w).SetHeight(h)
218+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
219+ r.w, r.h = w, h
220+ // The size is the client's own: core learns it from the commit
221+ // and Configure only resends it when core wants another.
222+ r.pend.w, r.pend.h = w, h
223+ r.sent.w, r.sent.h = w, h
224+ }
225+ }
226+ x.sendConfigureNotify(r)
227+}
228+
229+// configureBeforeReady honours the geometry a window asks for before it is
230+// ready (XMoveResize before XMapWindow), floating or not: core only sees the
231+// window at Ready and the client must find the geometry it set.
232+func (x *XWM) configureBeforeReady(s Slot, r *record, e xproto.ConfigureRequestEvent) {
233+ mask := e.ValueMask()
234+ var v xproto.ConfigureWindowValues
235+ if mask&xproto.ConfigWindowX != 0 {
236+ r.x = int32(e.X())
237+ v.SetX(r.x)
238+ }
239+ if mask&xproto.ConfigWindowY != 0 {
240+ r.y = int32(e.Y())
241+ v.SetY(r.y)
242+ }
243+ if mask&xproto.ConfigWindowWidth != 0 {
244+ r.w, r.sized = uint32(min(max(int(e.Width()), 1), maxClientSize)), true
245+ v.SetWidth(r.w)
246+ }
247+ if mask&xproto.ConfigWindowHeight != 0 {
248+ r.h, r.sized = uint32(min(max(int(e.Height()), 1), maxClientSize)), true
249+ v.SetHeight(r.h)
250+ }
251+ if mask&(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
252+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
253+ }
254+ x.sendConfigureNotify(r)
255+}
256+
257+// onConfigureNotify keeps the known geometry of every top-level window.
258+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
259+ e := xproto.ConfigureNotifyOf(ev)
260+ if e.Event() != xproto.Window(x.root) {
261+ return
262+ }
263+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
264+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
265+ }
266+}
A internal/adapters/wayland/xwm/configure_test.go +565 -0
@@ -0,0 +1,565 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "bnema.dev/xturbo/xproto"
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// newWin makes a ready managed window known to the XWM and the mocked host.
14+func newWin(t *testing.T, x *XWM, p *xpeer, h *mockhost, xid uint32) Slot {
15+ t.Helper()
16+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(xid)).Once()
17+ p.createNotify(xid, false)
18+ readUntil(t, x, func() bool { return x.tab.lookup(xid) != 0 })
19+ s := x.tab.lookup(xid)
20+ r := x.tab.rec(s)
21+ r.ready, r.w, r.h = true, 300, 200
22+ return s
23+}
24+
25+// settle flushes the XWM and waits until the peer has seen everything sent so
26+// far; it returns the number of requests received.
27+func settle(t *testing.T, x *XWM, p *xpeer) int {
28+ t.Helper()
29+ x.flush()
30+ n := len(p.requests())
31+ xproto.GetInputFocus(x.conn.Buf())
32+ require.NoError(t, x.conn.Flush())
33+ _, n = p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
34+ return n
35+}
36+
37+// since returns the requests of the given major opcode received after skip.
38+func since(p *xpeer, skip int, major uint8) []xpeerReq {
39+ var out []xpeerReq
40+ for _, r := range p.requests()[skip:] {
41+ if r.major == major {
42+ out = append(out, r)
43+ }
44+ }
45+ return out
46+}
47+
48+// sentEvents returns the 32-byte events of the SendEvent requests to dest.
49+func sentEvents(p *xpeer, skip int, dest uint32) [][]byte {
50+ var out [][]byte
51+ for _, r := range since(p, skip, opSendEvent) {
52+ if r.u32(4) == dest {
53+ out = append(out, r.raw[12:44])
54+ }
55+ }
56+ return out
57+}
58+
59+func propsTo(p *xpeer, skip int, win uint32, atom string) []xpeerReq {
60+ var out []xpeerReq
61+ a := p.atom(atom)
62+ for _, r := range since(p, skip, opChangeProperty) {
63+ if r.u32(4) == win && r.u32(8) == a {
64+ out = append(out, r)
65+ }
66+ }
67+ return out
68+}
69+
70+func (x *XWM) feed(t *testing.T, p *xpeer, send func()) {
71+ t.Helper()
72+ ev := x.stats.Events
73+ send()
74+ readUntil(t, x, func() bool { return x.stats.Events > ev })
75+}
76+
77+func TestConfigureCoalescesIntoOneRequest(t *testing.T) {
78+ x, p, h, _, runs := attached(t)
79+ const win = 0x500001
80+ s := newWin(t, x, p, h, win)
81+ n := settle(t, x, p)
82+
83+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
84+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
85+ x.Place(s, 10, 20)
86+ require.Len(t, *runs, 1, "one flush posted")
87+ n2 := settle(t, x, p)
88+
89+ cw := since(p, n, opConfigureWindow)
90+ require.Len(t, cw, 1)
91+ require.Equal(t, uint32(win), cw[0].u32(4))
92+ require.EqualValues(t, 0x1f, binary.LittleEndian.Uint16(cw[0].raw[8:]), "x y width height border")
93+ require.Equal(t, []uint32{10, 20, 800, 600, 0}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20), cw[0].u32(24), cw[0].u32(28)})
94+
95+ evs := sentEvents(p, n, win)
96+ require.Len(t, evs, 1, "one synthetic ConfigureNotify")
97+ require.EqualValues(t, 22, evs[0][0])
98+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][4:]))
99+ require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][8:]))
100+ require.EqualValues(t, 10, binary.LittleEndian.Uint16(evs[0][16:]))
101+ require.EqualValues(t, 20, binary.LittleEndian.Uint16(evs[0][18:]))
102+ require.EqualValues(t, 800, binary.LittleEndian.Uint16(evs[0][20:]))
103+ require.EqualValues(t, 600, binary.LittleEndian.Uint16(evs[0][22:]))
104+ require.Equal(t, xproto.EventMaskStructureNotify, xproto.EventMask(since(p, n, opSendEvent)[0].u32(8)))
105+
106+ // Unchanged: nothing is sent.
107+ x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true})
108+ x.Place(s, 10, 20)
109+ settle(t, x, p)
110+ require.Len(t, since(p, n2, opConfigureWindow), 0)
111+ require.Len(t, sentEvents(p, n2, win), 0)
112+}
113+
114+func TestConfigureWritesNetWMState(t *testing.T) {
115+ x, p, h, _, _ := attached(t)
116+ const win = 0x500001
117+ s := newWin(t, x, p, h, win)
118+ x.tab.rec(s).modal = true
119+ n := settle(t, x, p)
120+
121+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100})
122+ settle(t, x, p)
123+ require.Empty(t, propsTo(p, n, win, "_NET_WM_STATE"), "no state bit changed")
124+
125+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Fullscreen: true, Activated: true})
126+ settle(t, x, p)
127+ st, ok := p.prop(win, "_NET_WM_STATE")
128+ require.True(t, ok)
129+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FULLSCREEN"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
130+
131+ x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Activated: true})
132+ settle(t, x, p)
133+ st, _ = p.prop(win, "_NET_WM_STATE")
134+ require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
135+}
136+
137+func TestConfigureBadWindowIsAttributedAndIgnored(t *testing.T) {
138+ x, p, h, _, _ := attached(t)
139+ const win = 0x500001
140+ s := newWin(t, x, p, h, win)
141+ n := settle(t, x, p)
142+ x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480})
143+ x.flush()
144+ cw := []xpeerReq{}
145+ r, _ := p.waitReq(n, func(r xpeerReq) bool { return r.major == opConfigureWindow })
146+ cw = append(cw, r)
147+
148+ p.mu.Lock()
149+ e := make([]byte, 32)
150+ e[1] = xproto.BadWindow
151+ binary.LittleEndian.PutUint16(e[2:], cw[0].seq)
152+ binary.LittleEndian.PutUint32(e[4:], win)
153+ e[10] = opConfigureWindow
154+ p.write(e)
155+ p.mu.Unlock()
156+ readUntil(t, x, func() bool { return x.stats.Errors == 1 })
157+ require.Zero(t, x.stats.Unattributed)
158+ require.False(t, x.closed)
159+}
160+
161+func sendConfigureRequest(p *xpeer, win uint32, mask uint16, x, y int16, w, h uint16) {
162+ p.event(23, func(b *[32]byte) {
163+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
164+ binary.LittleEndian.PutUint32(b[8:], win)
165+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
166+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
167+ binary.LittleEndian.PutUint16(b[20:], w)
168+ binary.LittleEndian.PutUint16(b[22:], h)
169+ binary.LittleEndian.PutUint16(b[26:], mask)
170+ })
171+}
172+
173+func TestConfigureRequestTiledOnlyGetsNotify(t *testing.T) {
174+ x, p, h, _, _ := attached(t)
175+ const win = 0x500001
176+ newWin(t, x, p, h, win)
177+ n := settle(t, x, p)
178+
179+ x.feed(t, p, func() { sendConfigureRequest(p, win, 0xf, 5, 6, 1000, 900) })
180+ settle(t, x, p)
181+ require.Empty(t, since(p, n, opConfigureWindow), "a tiled window cannot resize itself")
182+ evs := sentEvents(p, n, win)
183+ require.Len(t, evs, 1)
184+ require.EqualValues(t, 22, evs[0][0])
185+ require.EqualValues(t, 300, binary.LittleEndian.Uint16(evs[0][20:]), "current geometry")
186+ require.EqualValues(t, 200, binary.LittleEndian.Uint16(evs[0][22:]))
187+}
188+
189+func TestConfigureRequestFloatingHonoursSize(t *testing.T) {
190+ x, p, h, _, _ := attached(t)
191+ const win = 0x500001
192+ s := newWin(t, x, p, h, win)
193+ x.tab.rec(s).floating = true
194+ n := settle(t, x, p)
195+
196+ x.feed(t, p, func() {
197+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight), 0, 0, 70000&0xffff, 20000)
198+ })
199+ settle(t, x, p)
200+ cw := since(p, n, opConfigureWindow)
201+ require.Len(t, cw, 1)
202+ require.EqualValues(t, xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, binary.LittleEndian.Uint16(cw[0].raw[8:]))
203+ require.EqualValues(t, 4464, cw[0].u32(12))
204+ require.EqualValues(t, maxClientSize, cw[0].u32(16), "clamped")
205+ require.Len(t, sentEvents(p, n, win), 1)
206+}
207+
208+func TestConfigureRequestUnmanagedHonouredVerbatim(t *testing.T) {
209+ x, p, _, _, _ := attached(t)
210+ const win = 0x500009
211+ p.createNotify(win, true)
212+ readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 })
213+ n := settle(t, x, p)
214+
215+ x.feed(t, p, func() {
216+ sendConfigureRequest(p, win, uint16(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth), 30, 40, 50, 60)
217+ })
218+ settle(t, x, p)
219+ cw := since(p, n, opConfigureWindow)
220+ require.Len(t, cw, 1)
221+ require.EqualValues(t, 7, binary.LittleEndian.Uint16(cw[0].raw[8:]))
222+ require.Equal(t, []uint32{30, 40, 50}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20)})
223+ require.Empty(t, sentEvents(p, n, win))
224+}
225+
226+func TestConfigureStaleAndDuplicateDirtySlots(t *testing.T) {
227+ x, p, h, _, _ := attached(t)
228+ const a, b = 0x500001, 0x500002
229+ s := newWin(t, x, p, h, a)
230+ for i := 1; i <= 50; i++ {
231+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i, Height: 100})
232+ }
233+ require.Len(t, x.dirty, 1, "queued once per false→true transition")
234+
235+ // The window dies with its entry queued; the slot is reused.
236+ h.EXPECT().Dissociate(s, true).Once()
237+ p.destroyNotify(a)
238+ readUntil(t, x, func() bool { return x.tab.lookup(a) == 0 })
239+ s2 := newWin(t, x, p, h, b)
240+ require.Equal(t, s, s2, "LIFO slot reuse")
241+ n := len(p.requests())
242+ x.Configure(s2, ports.ConfigureWindow{Width: 640, Height: 480})
243+ require.Len(t, x.dirty, 1)
244+ x.dirty = append(x.dirty, s, s) // stale and duplicate entries must be skipped
245+ settle(t, x, p)
246+ cw := since(p, n, opConfigureWindow)
247+ require.Len(t, cw, 1, "sent once despite duplicates")
248+ require.Equal(t, uint32(b), cw[0].u32(4))
249+ require.Empty(t, x.dirty)
250+ require.LessOrEqual(t, cap(x.dirty), len(x.tab.recs))
251+}
252+
253+func TestDirtyQueueNeverGrows(t *testing.T) {
254+ x, p, h, _, _ := attached(t)
255+ c0 := cap(x.dirty)
256+ const base = 0x600000
257+ for i := 0; i < 3*c0; i++ { // each round leaves a stale entry behind
258+ xid := uint32(base + i)
259+ h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(1)).Once()
260+ p.createNotify(xid, false)
261+ readUntil(t, x, func() bool { return x.tab.lookup(xid) != 0 })
262+ s := x.tab.lookup(xid)
263+ x.tab.rec(s).ready = true
264+ x.Configure(s, ports.ConfigureWindow{Width: 10, Height: 10})
265+ h.EXPECT().Dissociate(s, true).Once()
266+ p.destroyNotify(xid)
267+ readUntil(t, x, func() bool { return x.tab.lookup(xid) == 0 })
268+ }
269+ require.Equal(t, c0, cap(x.dirty))
270+ require.LessOrEqual(t, len(x.dirty), c0)
271+}
272+
273+func TestPublicCommandsIgnoredWhenClosed(t *testing.T) {
274+ x, p, h, src, _ := attached(t)
275+ const win = 0x500001
276+ s := newWin(t, x, p, h, win)
277+ h.EXPECT().Dissociate(s, true).Once()
278+ src.EXPECT().Remove().Once()
279+ x.Close()
280+ require.NotPanics(t, func() {
281+ x.Configure(s, ports.ConfigureWindow{Width: 1, Height: 1})
282+ x.Place(s, 1, 2)
283+ x.Focus(s)
284+ x.CloseWindow(s)
285+ })
286+ require.Empty(t, x.dirty)
287+ require.Zero(t, x.active)
288+}
289+
290+// Focus.
291+
292+type focusModel struct {
293+ name string
294+ input, take bool
295+ setFocus bool // SetInputFocus on the window
296+ takeFocus bool // WM_TAKE_FOCUS sent
297+}
298+
299+func TestFocusInputModels(t *testing.T) {
300+ for _, m := range []focusModel{
301+ {"passive", true, false, true, false},
302+ {"locally active", true, true, true, true},
303+ {"globally active", false, true, false, true},
304+ {"no input", false, false, false, false},
305+ } {
306+ t.Run(m.name, func(t *testing.T) {
307+ x, p, h, _, _ := attached(t)
308+ const win = 0x500001
309+ s := newWin(t, x, p, h, win)
310+ r := x.tab.rec(s)
311+ r.input, r.takeFocus = m.input, m.take
312+ n := settle(t, x, p)
313+
314+ x.Focus(s)
315+ settle(t, x, p)
316+ var focuses []xpeerReq
317+ for _, r := range since(p, n, opSetInputFocus) {
318+ if r.u32(4) == win {
319+ focuses = append(focuses, r)
320+ }
321+ }
322+ if m.setFocus {
323+ require.Len(t, focuses, 1)
324+ require.EqualValues(t, xproto.InputFocusNone, focuses[0].raw[1], "never PointerRoot")
325+ require.Zero(t, focuses[0].u32(8), "CurrentTime")
326+ } else {
327+ require.Empty(t, focuses)
328+ }
329+ var takes int
330+ for _, ev := range sentEvents(p, n, win) {
331+ if ev[0] == 33 && binary.LittleEndian.Uint32(ev[8:]) == p.atom("WM_PROTOCOLS") {
332+ require.EqualValues(t, 32, ev[1])
333+ require.Equal(t, p.atom("WM_TAKE_FOCUS"), binary.LittleEndian.Uint32(ev[12:]))
334+ require.Zero(t, binary.LittleEndian.Uint32(ev[16:]), "CurrentTime")
335+ takes++
336+ }
337+ }
338+ require.Equal(t, map[bool]int{true: 1, false: 0}[m.takeFocus], takes)
339+
340+ act, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
341+ require.True(t, ok)
342+ require.Equal(t, []uint32{win}, atomsOf(act), "named even for No-input windows")
343+ st, ok := p.prop(win, "_NET_WM_STATE")
344+ require.True(t, ok)
345+ require.Contains(t, atomsOf(st), p.atom("_NET_WM_STATE_FOCUSED"))
346+ })
347+ }
348+}
349+
350+func TestFocusActiveWindowWrittenOnlyOnChange(t *testing.T) {
351+ x, p, h, _, _ := attached(t)
352+ const a, b = 0x500001, 0x500002
353+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
354+ n := settle(t, x, p)
355+
356+ x.Focus(sa)
357+ x.Focus(sa)
358+ settle(t, x, p)
359+ require.Len(t, propsTo(p, n, peerRoot, "_NET_ACTIVE_WINDOW"), 1)
360+
361+ x.Focus(sb)
362+ settle(t, x, p)
363+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
364+ require.Equal(t, []uint32{b}, atomsOf(act))
365+ sta, _ := p.prop(a, "_NET_WM_STATE")
366+ require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED")
367+ stb, _ := p.prop(b, "_NET_WM_STATE")
368+ require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
369+
370+ n = settle(t, x, p)
371+ x.Focus(0)
372+ settle(t, x, p)
373+ none := since(p, n, opSetInputFocus)
374+ require.Len(t, none, 1)
375+ require.Zero(t, none[0].u32(4), "focus None")
376+ require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1])
377+ act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
378+ require.Equal(t, []uint32{0}, atomsOf(act))
379+ stb, _ = p.prop(b, "_NET_WM_STATE")
380+ require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED"))
381+ require.Zero(t, x.active)
382+}
383+
384+func TestFocusStackingLists(t *testing.T) {
385+ x, p, h, _, _ := attached(t)
386+ const a, b, c = 0x500001, 0x500002, 0x500003
387+ sa := newWin(t, x, p, h, a)
388+ newWin(t, x, p, h, b)
389+ newWin(t, x, p, h, c)
390+ for _, w := range []uint32{a, b, c} {
391+ x.addClient(w)
392+ }
393+ settle(t, x, p)
394+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
395+ require.Equal(t, []uint32{a, b, c}, atomsOf(list))
396+ n := settle(t, x, p)
397+
398+ x.Focus(sa)
399+ settle(t, x, p)
400+ stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
401+ require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top")
402+ list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST")
403+ require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order")
404+ require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus")
405+ var raised bool
406+ for _, r := range since(p, n, opConfigureWindow) {
407+ if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) {
408+ require.EqualValues(t, xproto.StackModeAbove, r.u32(12))
409+ raised = true
410+ }
411+ }
412+ require.True(t, raised)
413+
414+ // A burst costs one write per list.
415+ n = settle(t, x, p)
416+ x.removeClient(b)
417+ x.removeClient(c)
418+ settle(t, x, p)
419+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1)
420+ require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1)
421+ stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING")
422+ require.Equal(t, []uint32{a}, atomsOf(stack))
423+}
424+
425+func TestDestroyedActiveWindowClearsActive(t *testing.T) {
426+ x, p, h, _, _ := attached(t)
427+ const win = 0x500001
428+ s := newWin(t, x, p, h, win)
429+ x.addClient(win)
430+ x.Focus(s)
431+ settle(t, x, p)
432+ h.EXPECT().Dissociate(s, true).Once()
433+ p.destroyNotify(win)
434+ readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 })
435+ settle(t, x, p)
436+ require.Zero(t, x.active)
437+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
438+ require.Equal(t, []uint32{0}, atomsOf(act))
439+ list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST")
440+ require.Empty(t, list.data)
441+}
442+
443+func TestFocusInDenied(t *testing.T) {
444+ x, p, h, _, _ := attached(t)
445+ const a, b = 0x500001, 0x500002
446+ sa := newWin(t, x, p, h, a)
447+ newWin(t, x, p, h, b)
448+ x.Focus(sa)
449+ n := settle(t, x, p)
450+
451+ focusIn := func(win uint32, detail byte) {
452+ p.event(9, func(e *[32]byte) {
453+ e[1] = detail
454+ binary.LittleEndian.PutUint32(e[4:], win)
455+ })
456+ }
457+ focusCount := func(skip int) int {
458+ c := 0
459+ for _, r := range since(p, skip, opSetInputFocus) {
460+ if r.u32(4) == a {
461+ c++
462+ }
463+ }
464+ return c
465+ }
466+ x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine
467+ x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) })
468+ settle(t, x, p)
469+ require.Zero(t, focusCount(n))
470+
471+ x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) })
472+ readUntil(t, x, func() bool { return x.stats.Events >= 5 })
473+ settle(t, x, p)
474+ require.Equal(t, 1, focusCount(n), "denied once per iteration")
475+ n = settle(t, x, p)
476+ settle(t, x, p)
477+ require.Zero(t, focusCount(n))
478+}
479+
480+func TestCloseWindow(t *testing.T) {
481+ x, p, h, _, _ := attached(t)
482+ const a, b = 0x500001, 0x500002
483+ sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b)
484+ x.tab.rec(sa).deleteWindow = true
485+ n := settle(t, x, p)
486+
487+ x.CloseWindow(sa)
488+ x.CloseWindow(sb)
489+ settle(t, x, p)
490+ evs := sentEvents(p, n, a)
491+ require.Len(t, evs, 1)
492+ require.EqualValues(t, 33, evs[0][0])
493+ require.EqualValues(t, 32, evs[0][1])
494+ require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:]))
495+ require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:]))
496+ require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:]))
497+ require.Empty(t, sentEvents(p, n, b))
498+
499+ kills := since(p, n, opKillClient)
500+ require.Len(t, kills, 1)
501+ require.Equal(t, uint32(b), kills[0].u32(4))
502+}
503+
504+// Allocation guards. The peer sinks requests so the connection never blocks;
505+// its goroutine is idle in the sink loop before measuring.
506+
507+func sinkPeer(t *testing.T, x *XWM, p *xpeer) {
508+ t.Helper()
509+ p.sink.Store(true)
510+ n := len(p.requests())
511+ xproto.GetInputFocus(x.conn.Buf()) // served by the old loop iteration
512+ require.NoError(t, x.conn.Flush())
513+ p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
514+ // The peer records a request and answers it under mu: once the lock is
515+ // free again it is back in its sink loop, which does not allocate.
516+ p.mu.Lock()
517+ _ = p.seq
518+ p.mu.Unlock()
519+ x.src = nil // no source: write backpressure would not allocate either
520+ x.post = func(func()) {}
521+ x.warnedRing = true // the sink never answers: Expect slots stay used
522+}
523+
524+func TestConfigureFlushAllocations(t *testing.T) {
525+ x, p, h, _, _ := attached(t)
526+ s := newWin(t, x, p, h, 0x500001)
527+ sinkPeer(t, x, p)
528+ i := 0
529+ allocs := testing.AllocsPerRun(300, func() {
530+ i++
531+ x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0})
532+ x.Place(s, i%5, i%7)
533+ x.posted = false
534+ x.flush()
535+ })
536+ require.Zero(t, allocs)
537+ require.False(t, x.closed)
538+}
539+
540+func TestFocusAllocations(t *testing.T) {
541+ x, p, h, _, _ := attached(t)
542+ a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002)
543+ x.addClient(x.tab.rec(a).xid)
544+ x.addClient(x.tab.rec(b).xid)
545+ x.tab.rec(b).takeFocus = true
546+ x.tab.rec(a).input, x.tab.rec(b).input = true, true
547+ sinkPeer(t, x, p)
548+ i := 0
549+ allocs := testing.AllocsPerRun(150, func() {
550+ i++
551+ switch i % 3 {
552+ case 0:
553+ x.Focus(a)
554+ case 1:
555+ x.Focus(b)
556+ default:
557+ x.Focus(0)
558+ }
559+ x.refocus = i%5 == 0
560+ x.posted = false
561+ x.flush()
562+ })
563+ require.Zero(t, allocs)
564+ require.False(t, x.closed)
565+}
A internal/adapters/wayland/xwm/events.go +252 -0
@@ -0,0 +1,252 @@
1+package xwm
2+
3+import (
4+ "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/composite"
6+ "bnema.dev/xturbo/res"
7+ "bnema.dev/xturbo/xfixes"
8+ "bnema.dev/xturbo/xproto"
9+)
10+
11+// Reply kinds registered with Expect. The first argument is the window tag
12+// (slot | generation<<16, see table.tag), the second a property index.
13+const (
14+ kindAttr uint16 = 16 + iota
15+ kindProp
16+ kindGeom
17+ kindClientIds
18+ kindConfigure
19+ kindFocus
20+ kindRootMask
21+ kindVersion
22+ kindTree
23+)
24+
25+// onEvent dispatches an X event. Before Attach there is no host: only the
26+// table is maintained, and Attach replays the result.
27+func (x *XWM) onEvent(ev *xturbo.Event) {
28+ x.stats.Events++
29+ if x.closed {
30+ return
31+ }
32+ switch ev.Kind {
33+ case xproto.KindCreateNotify:
34+ x.onCreateNotify(ev)
35+ case xproto.KindDestroyNotify:
36+ x.onDestroyNotify(ev)
37+ case xproto.KindReparentNotify:
38+ x.onReparentNotify(ev)
39+ }
40+ if x.host == nil {
41+ x.onEventDetached(ev)
42+ return
43+ }
44+ switch ev.Kind {
45+ case xproto.KindMapRequest:
46+ x.onMapRequest(ev)
47+ case xproto.KindMapNotify:
48+ x.onMapNotify(ev)
49+ case xproto.KindUnmapNotify:
50+ x.onUnmapNotify(ev)
51+ case xproto.KindConfigureRequest:
52+ x.onConfigureRequest(ev)
53+ case xproto.KindConfigureNotify:
54+ x.onConfigureNotify(ev)
55+ case xproto.KindPropertyNotify:
56+ x.onPropertyNotify(ev)
57+ case xproto.KindClientMessage:
58+ x.onClientMessage(ev)
59+ case xproto.KindFocusIn:
60+ x.onFocusIn(ev)
61+ }
62+}
63+
64+// onEventDetached keeps what Attach replays: windows asked to map, and those
65+// withdrawn again, so Attach skips them; geometry requests are answered.
66+func (x *XWM) onEventDetached(ev *xturbo.Event) {
67+ switch ev.Kind {
68+ case xproto.KindMapRequest:
69+ if s := x.tab.lookup(uint32(xproto.MapRequestOf(ev).Window())); s != 0 {
70+ x.tab.rec(s).mapping = true
71+ }
72+ case xproto.KindUnmapNotify:
73+ if ev.SendEvent {
74+ return
75+ }
76+ if s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window())); s != 0 {
77+ r := x.tab.rec(s)
78+ r.mapped, r.mapping = false, false
79+ }
80+ case xproto.KindConfigureRequest:
81+ x.onConfigureRequest(ev)
82+ }
83+}
84+
85+func (x *XWM) onReply(r *xturbo.Reply) {
86+ x.stats.Replies++
87+ if x.closed {
88+ return
89+ }
90+ switch r.Kind {
91+ case kindVersion:
92+ x.onVersionReply(r)
93+ return
94+ case kindTree:
95+ x.onTreeReply(r)
96+ return
97+ }
98+ s, rec := x.tab.live(r.Args[0])
99+ if rec == nil {
100+ x.stats.Stale++
101+ return
102+ }
103+ switch r.Kind {
104+ case kindAttr:
105+ x.onAttrReply(rec, r)
106+ case kindProp:
107+ x.onPropReply(s, rec, r)
108+ case kindGeom:
109+ x.onGeomReply(s, rec, r)
110+ case kindClientIds:
111+ x.onClientIdsReply(s, rec, r)
112+ }
113+}
114+
115+// onError never tears the XWM down: an error names a request, not the
116+// connection. Sticky Buf errors surface through Flush.
117+func (x *XWM) onError(e *xturbo.XError) {
118+ x.stats.Errors++
119+ if e.Kind == 0 {
120+ x.stats.Unattributed++
121+ x.log.Debug().Uint8("code", e.Code).Uint8("major", e.Major).Uint16("minor", e.Minor).
122+ Uint64("seq", uint64(e.Seq)).Msg("unattributed X error")
123+ return
124+ }
125+ switch e.Kind {
126+ case kindRootMask:
127+ if e.Code == xproto.BadAccess {
128+ x.bootErr = ErrWMExists
129+ }
130+ case kindProp, kindGeom, kindClientIds, kindConfigure, kindFocus:
131+ // Usually BadWindow: the window died between request and reply and
132+ // DestroyNotify follows. Whatever the code, the awaited reply will
133+ // never come, so the map must not wait for it.
134+ s, rec := x.tab.live(e.Args[0])
135+ if rec == nil {
136+ x.stats.Stale++
137+ return
138+ }
139+ switch e.Kind {
140+ case kindProp:
141+ rec.pending &^= 1 << e.Args[1]
142+ case kindGeom:
143+ rec.pending &^= propGeom
144+ case kindClientIds:
145+ rec.pending &^= propClientIDs
146+ default:
147+ return
148+ }
149+ if e.Code != xproto.BadWindow && e.Code != xproto.BadDrawable { // a dead window is never announced
150+ x.checkReady(s)
151+ }
152+ }
153+}
154+
155+// onCreateNotify records a new top-level window. Override-redirect windows
156+// are kept (their geometry and serial matter later) but never reach the host.
157+func (x *XWM) onCreateNotify(ev *xturbo.Event) {
158+ e := xproto.CreateNotifyOf(ev)
159+ xid := uint32(e.Window())
160+ if xid == x.wmWin || x.tab.lookup(xid) != 0 {
161+ return
162+ }
163+ s := x.insert(xid)
164+ if s == 0 {
165+ return
166+ }
167+ r := x.tab.rec(s)
168+ r.override = e.OverrideRedirect()
169+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
170+ if r.override {
171+ r.kind = kindUnmanaged
172+ x.stats.UnmanagedSeen++
173+ return
174+ }
175+ if x.host != nil {
176+ x.adopt(s)
177+ }
178+}
179+
180+// adopt announces a managed window to the host and starts watching it.
181+func (x *XWM) adopt(s Slot) {
182+ r := x.tab.rec(s)
183+ r.id = x.host.Created(s, r.xid)
184+ var v xproto.ChangeWindowAttributesValues
185+ v.SetEventMask(xproto.EventMaskPropertyChange | xproto.EventMaskFocusChange)
186+ x.expect(xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(r.xid), &v), kindAttr, x.tab.tag(s), 0)
187+ x.log.Debug().Uint32("xid", r.xid).Uint32("slot", uint32(s)).Msg("x11 window created")
188+}
189+
190+func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
191+ xid := uint32(xproto.DestroyNotifyOf(ev).Window())
192+ s := x.tab.lookup(xid)
193+ if s == 0 {
194+ return
195+ }
196+ x.forgetWindow(s)
197+}
198+
199+// onTreeReply (boot): record every existing child and ask for its attributes.
200+func (x *XWM) onTreeReply(r *xturbo.Reply) {
201+ q, ok := xproto.ParseQueryTreeReply(r.Raw)
202+ if !ok {
203+ return
204+ }
205+ children := q.Children()
206+ for i := 0; i < children.Len(); i++ {
207+ xid := uint32(children.At(i))
208+ if xid == x.wmWin || x.tab.lookup(xid) != 0 {
209+ continue
210+ }
211+ s := x.insert(xid)
212+ if s == 0 {
213+ return
214+ }
215+ x.expect(xproto.GetWindowAttributes(x.conn.Buf(), xproto.Window(xid)), kindAttr, x.tab.tag(s), 0)
216+ }
217+}
218+
219+// onAttrReply (boot): classify an existing window; viewable ones go through
220+// the map flow once the host is attached.
221+func (x *XWM) onAttrReply(rec *record, r *xturbo.Reply) {
222+ a, ok := xproto.ParseGetWindowAttributesReply(r.Raw)
223+ if !ok || x.host != nil {
224+ return
225+ }
226+ if a.OverrideRedirect() {
227+ rec.override = true
228+ rec.kind = kindUnmanaged
229+ x.stats.UnmanagedSeen++
230+ return
231+ }
232+ if a.MapState() == xproto.MapStateViewable {
233+ rec.mapped, rec.mapping = true, true
234+ }
235+}
236+
237+func (x *XWM) onVersionReply(r *xturbo.Reply) {
238+ switch r.Args[0] {
239+ case versionComposite:
240+ if v, ok := composite.ParseQueryVersionReply(r.Raw); ok {
241+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("Composite")
242+ }
243+ case versionXFixes:
244+ if v, ok := xfixes.ParseQueryVersionReply(r.Raw); ok {
245+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("XFixes")
246+ }
247+ case versionRes:
248+ if v, ok := res.ParseQueryVersionReply(r.Raw); ok {
249+ x.log.Info().Uint16("major", v.ServerMajor()).Uint16("minor", v.ServerMinor()).Msg("X-Resource")
250+ }
251+ }
252+}
A internal/adapters/wayland/xwm/fdsource_mock_test.go +129 -0
@@ -0,0 +1,129 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwm
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockfdSource creates a new instance of mockfdSource. 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 newMockfdSource(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockfdSource {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockfdSource{}
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+// mockfdSource is an autogenerated mock type for the fdSource type
35+type mockfdSource struct {
36+ mock.Mock
37+}
38+
39+type mockfdSource_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockfdSource) EXPECT() *mockfdSource_Expecter {
44+ return &mockfdSource_Expecter{mock: &_m.Mock}
45+}
46+
47+// Remove provides a mock function for the type mockfdSource
48+func (_mock *mockfdSource) Remove() {
49+ _mock.Called()
50+ return
51+}
52+
53+// mockfdSource_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove'
54+type mockfdSource_Remove_Call struct {
55+ *mock.Call
56+}
57+
58+// Remove is a helper method to define mock.On call
59+func (_e *mockfdSource_Expecter) Remove() *mockfdSource_Remove_Call {
60+ return &mockfdSource_Remove_Call{Call: _e.mock.On("Remove")}
61+}
62+
63+func (_c *mockfdSource_Remove_Call) Run(run func()) *mockfdSource_Remove_Call {
64+ _c.Call.Run(func(args mock.Arguments) {
65+ run()
66+ })
67+ return _c
68+}
69+
70+func (_c *mockfdSource_Remove_Call) Return() *mockfdSource_Remove_Call {
71+ _c.Call.Return()
72+ return _c
73+}
74+
75+func (_c *mockfdSource_Remove_Call) RunAndReturn(run func()) *mockfdSource_Remove_Call {
76+ _c.Run(run)
77+ return _c
78+}
79+
80+// SetWritable provides a mock function for the type mockfdSource
81+func (_mock *mockfdSource) SetWritable(on bool) error {
82+ ret := _mock.Called(on)
83+
84+ if len(ret) == 0 {
85+ panic("no return value specified for SetWritable")
86+ }
87+
88+ var r0 error
89+ if returnFunc, ok := ret.Get(0).(func(bool) error); ok {
90+ r0 = returnFunc(on)
91+ } else {
92+ r0 = ret.Error(0)
93+ }
94+ return r0
95+}
96+
97+// mockfdSource_SetWritable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetWritable'
98+type mockfdSource_SetWritable_Call struct {
99+ *mock.Call
100+}
101+
102+// SetWritable is a helper method to define mock.On call
103+// - on bool
104+func (_e *mockfdSource_Expecter) SetWritable(on any) *mockfdSource_SetWritable_Call {
105+ return &mockfdSource_SetWritable_Call{Call: _e.mock.On("SetWritable", on)}
106+}
107+
108+func (_c *mockfdSource_SetWritable_Call) Run(run func(on bool)) *mockfdSource_SetWritable_Call {
109+ _c.Call.Run(func(args mock.Arguments) {
110+ var arg0 bool
111+ if args[0] != nil {
112+ arg0 = args[0].(bool)
113+ }
114+ run(
115+ arg0,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *mockfdSource_SetWritable_Call) Return(err error) *mockfdSource_SetWritable_Call {
122+ _c.Call.Return(err)
123+ return _c
124+}
125+
126+func (_c *mockfdSource_SetWritable_Call) RunAndReturn(run func(on bool) error) *mockfdSource_SetWritable_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
A internal/adapters/wayland/xwm/fixes_test.go +230 -0
@@ -0,0 +1,230 @@
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/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func getProps(p *xpeer, skip int, win uint32) int {
15+ n := 0
16+ for _, r := range since(p, skip, opGetProperty) {
17+ if r.u32(4) == win {
18+ n++
19+ }
20+ }
21+ return n
22+}
23+
24+// Our own _NET_WM_STATE writes must not be read back once the window is ready.
25+func TestOwnNetWMStateWriteIsNotReadBack(t *testing.T) {
26+ hs := newHarness(t, nil)
27+ const win = 0x500001
28+ hs.classic(win)
29+ s := hs.mapAndWait(win)
30+
31+ hs.x.Configure(s, ports.ConfigureWindow{Width: 300, Height: 200, Fullscreen: true})
32+ n := settle(t, hs.x, hs.p)
33+ require.True(t, hs.x.tab.rec(s).fullscreen, "the written state is the known state")
34+ st, _ := hs.p.prop(win, "_NET_WM_STATE")
35+ require.Equal(t, []uint32{hs.p.atom("_NET_WM_STATE_FULLSCREEN")}, atomsOf(st))
36+
37+ ev := hs.x.Stats().Events
38+ hs.p.propertyNotify(win, hs.p.atom("_NET_WM_STATE"), false)
39+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events > ev })
40+ settle(t, hs.x, hs.p)
41+ require.Zero(t, getProps(hs.p, n, win), "no GetProperty")
42+
43+}
44+
45+// A pre-map XMoveResize is honoured, floating or not.
46+func TestConfigureRequestBeforeReadyIsHonoured(t *testing.T) {
47+ hs := newHarness(t, nil)
48+ const win = 0x500001
49+ hs.classic(win)
50+ hs.create(win)
51+ n := settle(t, hs.x, hs.p)
52+
53+ mask := uint16(xproto.ConfigWindowX | xproto.ConfigWindowY | xproto.ConfigWindowWidth | xproto.ConfigWindowHeight)
54+ hs.x.feed(t, hs.p, func() { sendConfigureRequest(hs.p, win, mask, 11, 22, 640, 70000&0xffff) })
55+ settle(t, hs.x, hs.p)
56+ cw := since(hs.p, n, opConfigureWindow)
57+ require.Len(t, cw, 1)
58+ require.Equal(t, []uint32{11, 22, 640, 4464}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20), cw[0].u32(24)})
59+ require.Len(t, sentEvents(hs.p, n, win), 1)
60+
61+ hs.expectReady(win)
62+ hs.p.mapRequest(win)
63+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
64+ require.Equal(t, 640, hs.ready[win].Width)
65+ require.Equal(t, 4464, hs.ready[win].Height)
66+ require.False(t, hs.ready[win].Floating)
67+}
68+
69+// After a remap the state is rewritten from scratch, FOCUSED included.
70+func TestRemapRewritesStateWithFocused(t *testing.T) {
71+ hs := newHarness(t, nil)
72+ const win = 0x500001
73+ hs.classic(win)
74+ s := hs.mapAndWait(win)
75+ hs.x.Focus(s)
76+ settle(t, hs.x, hs.p)
77+ st, _ := hs.p.prop(win, "_NET_WM_STATE")
78+ require.Equal(t, []uint32{hs.p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
79+
80+ hs.h.EXPECT().Dissociate(s, false).Once()
81+ hs.p.unmapNotify(win, false)
82+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).mapping })
83+ delete(hs.ready, win)
84+ hs.expectReady(win)
85+ hs.p.mapRequest(win)
86+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
87+
88+ hs.x.Focus(s)
89+ settle(t, hs.x, hs.p)
90+ st, _ = hs.p.prop(win, "_NET_WM_STATE")
91+ require.Equal(t, []uint32{hs.p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st))
92+}
93+
94+// Replies of a previous map never satisfy the next one (xturbo usually retires
95+// their slots first; the generation guards the rest).
96+func TestStaleRepliesOfPreviousMapAreDropped(t *testing.T) {
97+ hs := newHarness(t, nil)
98+ const win = 0x500001
99+ hs.classic(win)
100+ s := hs.create(win)
101+ hs.p.holdReplies() // the second map is never answered
102+ hs.p.mapRequest(win)
103+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).mapping })
104+ oldTag := hs.x.tab.tag(s)
105+ hs.h.EXPECT().Dissociate(s, false).Once()
106+ hs.p.unmapNotify(win, false)
107+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).mapping })
108+ hs.p.mapRequest(win)
109+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).mapping })
110+ require.NotEqual(t, oldTag, hs.x.tab.tag(s))
111+
112+ rep := make([]byte, 32)
113+ rep[0] = 1
114+ for idx := 0; idx < propCount; idx++ {
115+ hs.x.onReply(&xturbo.Reply{Kind: kindProp, Args: [4]uint32{oldTag, uint32(idx)}, Raw: rep})
116+ }
117+ hs.x.onReply(&xturbo.Reply{Kind: kindGeom, Args: [4]uint32{oldTag}, Raw: rep})
118+ require.EqualValues(t, propCount+1, hs.x.Stats().Stale)
119+ require.False(t, hs.x.tab.rec(s).ready)
120+ require.Equal(t, propMaskAll&^propClientIDs, hs.x.tab.rec(s).pending, "the new map still awaits its own replies")
121+}
122+
123+func TestGetGeometryBadDrawableIsNotReady(t *testing.T) {
124+ hs := newHarness(t, nil)
125+ const win = 0x500001
126+ hs.classic(win)
127+ s := hs.create(win)
128+ hs.p.set(func(p *xpeer) { p.bad[win] = true })
129+ hs.p.mapRequest(win)
130+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Errors >= uint64(propCount)+1 })
131+ require.Zero(t, hs.x.Stats().Unattributed)
132+ require.False(t, hs.x.tab.rec(s).ready)
133+ hs.h.EXPECT().Dissociate(s, true).Once()
134+ hs.p.destroyNotify(win)
135+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
136+}
137+
138+// A host that refocuses the window while dissociating it must not leave a
139+// dangling active slot.
140+func TestForgetWindowClearsActiveSetByHost(t *testing.T) {
141+ x, p, h, _, _ := attached(t)
142+ const win = 0x500001
143+ s := newWin(t, x, p, h, win)
144+ h.EXPECT().Dissociate(s, true).Run(func(sl Slot, _ bool) { x.Focus(sl) }).Once()
145+ p.destroyNotify(win)
146+ readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 })
147+ require.Zero(t, x.active)
148+ require.False(t, x.refocus)
149+ settle(t, x, p)
150+ act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
151+ require.Equal(t, []uint32{0}, atomsOf(act))
152+}
153+
154+func TestTitleAndAppIDAreValidUTF8(t *testing.T) {
155+ hs := newHarness(t, nil)
156+ const win = 0x500001
157+ hs.classic(win)
158+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_NAME"]) })
159+ hs.p.setProp(win, uint32(xproto.AtomWmName), uint32(xproto.AtomString), 8, []byte("caf\xe9"))
160+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("i\x00C\xe9\x00"))
161+ s := hs.mapAndWait(win)
162+ require.Equal(t, "caf\uFFFD", hs.ready[win].Title)
163+ require.Equal(t, "C\uFFFD", hs.ready[win].AppID)
164+
165+ // Unchanged after sanitising: no host call.
166+ hs.notifyWMName(win)
167+ // A changed truncated multibyte title reaches the host sanitised.
168+ hs.h.EXPECT().Title(s, "\u00e9\uFFFD").Once()
169+ hs.p.setProp(win, uint32(xproto.AtomWmName), uint32(xproto.AtomString), 8, []byte("\xc3\xa9\xe2\x82"))
170+ hs.notifyWMName(win)
171+}
172+
173+func TestSyntheticConfigureNotifyClampsCoordinates(t *testing.T) {
174+ x, p, h, _, _ := attached(t)
175+ const win = 0x500001
176+ s := newWin(t, x, p, h, win)
177+ n := settle(t, x, p)
178+ r := x.tab.rec(s)
179+ r.x, r.y, r.w, r.h = 70000, -70000, 70000, 5
180+ x.sendConfigureNotify(r)
181+ settle(t, x, p)
182+ ev := sentEvents(p, n, win)
183+ require.Len(t, ev, 1)
184+ require.EqualValues(t, 32767, int16(binary.LittleEndian.Uint16(ev[0][16:])))
185+ require.EqualValues(t, -32768, int16(binary.LittleEndian.Uint16(ev[0][18:])))
186+ require.EqualValues(t, 65535, binary.LittleEndian.Uint16(ev[0][20:]))
187+ require.EqualValues(t, 5, binary.LittleEndian.Uint16(ev[0][22:]))
188+}
189+
190+// A window withdrawn before Attach is not mapped by Attach; a pre-attach
191+// ConfigureRequest is honoured.
192+func TestUnmapAndConfigureBeforeAttach(t *testing.T) {
193+ x, p := bootPeer(t, nil)
194+ const gone, sized = 0x500001, 0x500002
195+ for _, w := range []uint32{gone, sized} {
196+ p.setProp(w, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("a\x00B\x00"))
197+ p.geoms[w] = xpeerGeom{w: 300, h: 200}
198+ p.createNotify(w, false)
199+ p.mapRequest(w)
200+ }
201+ p.unmapNotify(gone, false)
202+ mask := uint16(xproto.ConfigWindowWidth | xproto.ConfigWindowHeight)
203+ sendConfigureRequest(p, sized, mask, 0, 0, 500, 400)
204+ readUntil(t, x, func() bool { return x.Stats().Events >= 6 })
205+ n := len(p.requests())
206+
207+ h := newMockhost(t)
208+ src := newMockfdSource(t)
209+ src.EXPECT().Remove().Maybe()
210+ post, _ := newPost()
211+ var got WindowInfo
212+ done := false
213+ h.EXPECT().Created(mock.Anything, mock.Anything).Return(ports.WindowID(1)).Twice()
214+ h.EXPECT().Ready(mock.Anything, mock.Anything).Run(func(_ Slot, i WindowInfo) { got, done = i, true }).Once()
215+ x.attach(src, h, post)
216+ readUntil(t, x, func() bool { return done })
217+ settle(t, x, p)
218+ for _, r := range since(p, n, opMapWindow) {
219+ require.NotEqual(t, uint32(gone), r.u32(4), "withdrawn window must not be mapped")
220+ }
221+ require.Equal(t, 500, got.Width)
222+ require.Equal(t, 400, got.Height)
223+}
224+
225+func (hs *harness) notifyWMName(win uint32) {
226+ hs.t.Helper()
227+ before := hs.x.Stats().Replies
228+ hs.p.propertyNotify(win, uint32(xproto.AtomWmName), false)
229+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
230+}
A internal/adapters/wayland/xwm/focus.go +203 -0
@@ -0,0 +1,203 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "slices"
6+
7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+)
10+
11+// Bits of XWM.listsDirty: the client lists to rewrite at flush.
12+const (
13+ listClients uint8 = 1 << iota
14+ listStacking
15+)
16+
17+// onFocusIn denies a focus change the XWM did not make: Wayland is the truth.
18+// The active window's focus is re-issued once at the next flush.
19+func (x *XWM) onFocusIn(ev *xturbo.Event) {
20+ e := xproto.FocusInOf(ev)
21+ if e.Detail() == xproto.NotifyDetailPointer {
22+ return
23+ }
24+ s := x.tab.lookup(uint32(e.Event()))
25+ if s == 0 || s == x.active {
26+ return
27+ }
28+ x.refocus = true
29+ x.wake()
30+}
31+
32+// Focus gives keyboard focus to s; 0 means a native Wayland window or none.
33+// Focus never uses PointerRoot: without an X window to focus it is None.
34+func (x *XWM) Focus(s Slot) {
35+ if x.closed {
36+ return
37+ }
38+ r := x.managedRec(s)
39+ if r == nil {
40+ s = 0
41+ }
42+ if s == x.active {
43+ return
44+ }
45+ if old := x.managedRec(x.active); old != nil {
46+ old.pend.activated = false
47+ x.markDirty(x.active, old)
48+ }
49+ x.active = s
50+ if r == nil {
51+ x.issueFocus(0, nil)
52+ x.setActive(0)
53+ x.wake()
54+ return
55+ }
56+ r.pend.activated = true
57+ x.markDirty(s, r)
58+ x.issueFocus(s, r)
59+ x.setActive(r.xid)
60+ x.raise(r)
61+ x.wake()
62+}
63+
64+// issueFocus applies the ICCCM input model of r: Passive and Locally active
65+// windows get SetInputFocus, Locally and Globally active ones WM_TAKE_FOCUS,
66+// No-input windows nothing. A nil r clears the X focus.
67+func (x *XWM) issueFocus(s Slot, r *record) {
68+ b := x.conn.Buf()
69+ if r == nil {
70+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
71+ return
72+ }
73+ if r.input {
74+ x.expect(xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.Window(r.xid), 0), kindFocus, x.tab.tag(s), 0)
75+ }
76+ if r.takeFocus {
77+ x.sendProtocol(r.xid, atomWMTakeFocus)
78+ }
79+}
80+
81+// sendProtocol sends a WM_PROTOCOLS client message [proto, CurrentTime].
82+func (x *XWM) sendProtocol(xid uint32, proto int) {
83+ var raw [32]byte
84+ raw[0], raw[1] = 33, 32 // ClientMessage, format 32
85+ binary.LittleEndian.PutUint32(raw[4:], xid)
86+ binary.LittleEndian.PutUint32(raw[8:], x.atoms[atomWMProtocols])
87+ binary.LittleEndian.PutUint32(raw[12:], x.atoms[proto])
88+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(xid), xproto.EventMaskNoEvent, &raw)
89+}
90+
91+// setActive writes _NET_ACTIVE_WINDOW on the root.
92+func (x *XWM) setActive(xid uint32) {
93+ v := [1]uint32{xid}
94+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, v[:])
95+}
96+
97+// raise restacks r above its siblings and at the top of the stacking list.
98+func (x *XWM) raise(r *record) {
99+ var v xproto.ConfigureWindowValues
100+ v.SetStackMode(xproto.StackModeAbove)
101+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v)
102+ if i := slices.Index(x.stacking, r.xid); i >= 0 {
103+ x.stacking = append(x.stacking[:i], x.stacking[i+1:]...)
104+ x.stacking = append(x.stacking, r.xid)
105+ x.listsDirty |= listStacking
106+ }
107+}
108+
109+// CloseWindow asks s to close (WM_DELETE_WINDOW), or kills its client.
110+func (x *XWM) CloseWindow(s Slot) {
111+ r := x.managedRec(s)
112+ if r == nil {
113+ return
114+ }
115+ if r.deleteWindow {
116+ x.sendProtocol(r.xid, atomWMDeleteWindow)
117+ } else {
118+ xproto.KillClient(x.conn.Buf(), r.xid)
119+ }
120+ x.wake()
121+}
122+
123+// addClient puts a ready window at the end of both client lists.
124+func (x *XWM) addClient(xid uint32) {
125+ if !slices.Contains(x.clients, xid) {
126+ x.clients = append(x.clients, xid)
127+ x.listsDirty |= listClients
128+ }
129+ if !slices.Contains(x.stacking, xid) {
130+ x.stacking = append(x.stacking, xid)
131+ x.listsDirty |= listStacking
132+ }
133+ x.wake()
134+}
135+
136+// removeClient drops an unmapped or destroyed window from both client lists.
137+func (x *XWM) removeClient(xid uint32) {
138+ if i := slices.Index(x.clients, xid); i >= 0 {
139+ x.clients = slices.Delete(x.clients, i, i+1)
140+ x.listsDirty |= listClients
141+ }
142+ if i := slices.Index(x.stacking, xid); i >= 0 {
143+ x.stacking = slices.Delete(x.stacking, i, i+1)
144+ x.listsDirty |= listStacking
145+ }
146+ x.wake()
147+}
148+
149+// dropWindow takes s out of the client lists, the focus and the configure
150+// queue when it stops being mapped (unmap, destroy). It is idempotent.
151+func (x *XWM) dropWindow(s Slot) {
152+ r := x.tab.rec(s)
153+ if r == nil || r.kind != kindManaged {
154+ return
155+ }
156+ x.removeClient(r.xid)
157+ r.pend.dirty = false // the queued slot is skipped at flush
158+ // A remap starts from a clean _NET_WM_STATE, so Focus writes FOCUSED again.
159+ r.pend.activated, r.sent.activated = false, false
160+ r.sent.fullscreen, r.stateSent = false, 0
161+ if x.active == s {
162+ x.active = 0
163+ x.refocus = false
164+ x.setActive(0)
165+ }
166+}
167+
168+// forgetWindow ends the life of s (destroy, reparent away): it is dropped,
169+// the host is told if it knew the window, and the record is freed.
170+func (x *XWM) forgetWindow(s Slot) {
171+ r := x.tab.rec(s)
172+ if r == nil || r.kind == kindFree {
173+ return
174+ }
175+ x.dropWindow(s)
176+ if r.kind == kindManaged && r.id != 0 && x.host != nil {
177+ r.id = 0
178+ x.host.Dissociate(s, true)
179+ if x.active == s { // the host re-focused the window while dissociating it
180+ x.active, x.refocus = 0, false
181+ x.issueFocus(0, nil)
182+ x.setActive(0)
183+ x.wake()
184+ }
185+ }
186+ x.tab.remove(s)
187+}
188+
189+// flushFocus rewrites the client lists that changed and re-issues a denied
190+// focus, once per display iteration.
191+func (x *XWM) flushFocus() {
192+ if x.listsDirty&listClients != 0 {
193+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
194+ }
195+ if x.listsDirty&listStacking != 0 {
196+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
197+ }
198+ x.listsDirty = 0
199+ if x.refocus {
200+ x.refocus = false
201+ x.issueFocus(x.active, x.managedRec(x.active))
202+ }
203+}
A internal/adapters/wayland/xwm/host.go +49 -0
@@ -0,0 +1,49 @@
1+package xwm
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+// Slot is the XWM's window slot: table index + 1, stable until the window is
6+// destroyed. 0 means none.
7+type Slot uint32
8+
9+// WindowInfo is what the XWM learned about a managed window by the time it is
10+// ready to be mapped. The host copies it.
11+type WindowInfo struct {
12+ PID int
13+ Floating bool // dialog/utility/splash/fixed size
14+ Width, Height int // requested size, logical (scale 1)
15+ Parent Slot
16+ Modal bool // _NET_WM_STATE_MODAL
17+ Fullscreen bool // _NET_WM_STATE_FULLSCREEN at map
18+ Title, AppID string
19+}
20+
21+// host is the wayland server as the XWM sees it. Display goroutine only.
22+// All strings passed are owned by the XWM record and already deduplicated
23+// (only passed when changed).
24+type host interface {
25+ // Created: a managed (non-override-redirect) X window is known; the host
26+ // allocates a ports.WindowID and returns it. Not yet mapped.
27+ Created(slot Slot, xid uint32) ports.WindowID
28+ // Ready: properties read and MapWindow issued; the surface may still be
29+ // missing. Info is copied by the host.
30+ Ready(slot Slot, info WindowInfo)
31+ // Associate links the X window to the wl_surface with identity surfaceID.
32+ // The host maps the window on the surface's first buffer commit.
33+ Associate(slot Slot, surfaceID uint64)
34+ // Dissociate: UnmapNotify/DestroyNotify; the host emits WindowUnmapped if
35+ // mapped and drops the surface link. destroyed also frees the WindowID.
36+ Dissociate(slot Slot, destroyed bool)
37+ Title(slot Slot, title string) // changed only
38+ AppID(slot Slot, appID string) // changed only (WM_CLASS class)
39+ Parent(slot Slot, parent Slot) // 0 = none; after a WM_TRANSIENT_FOR change
40+ // Fullscreen forwards a fullscreen request (_NET_WM_STATE client message
41+ // or initial state); the host emits WindowFullscreenRequest.
42+ Fullscreen(slot Slot, on bool)
43+}
44+
45+// fdSource is the display's event source for the X socket (*server.Source).
46+type fdSource interface {
47+ SetWritable(on bool) error
48+ Remove()
49+}
A internal/adapters/wayland/xwm/host_mock_test.go +425 -0
@@ -0,0 +1,425 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwm
6+
7+import (
8+ "github.com/bnema/neferwl/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockhost creates a new instance of mockhost. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockhost(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockhost {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockhost{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockhost is an autogenerated mock type for the host type
36+type mockhost struct {
37+ mock.Mock
38+}
39+
40+type mockhost_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockhost) EXPECT() *mockhost_Expecter {
45+ return &mockhost_Expecter{mock: &_m.Mock}
46+}
47+
48+// AppID provides a mock function for the type mockhost
49+func (_mock *mockhost) AppID(slot Slot, appID string) {
50+ _mock.Called(slot, appID)
51+ return
52+}
53+
54+// mockhost_AppID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppID'
55+type mockhost_AppID_Call struct {
56+ *mock.Call
57+}
58+
59+// AppID is a helper method to define mock.On call
60+// - slot Slot
61+// - appID string
62+func (_e *mockhost_Expecter) AppID(slot any, appID any) *mockhost_AppID_Call {
63+ return &mockhost_AppID_Call{Call: _e.mock.On("AppID", slot, appID)}
64+}
65+
66+func (_c *mockhost_AppID_Call) Run(run func(slot Slot, appID string)) *mockhost_AppID_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 Slot
69+ if args[0] != nil {
70+ arg0 = args[0].(Slot)
71+ }
72+ var arg1 string
73+ if args[1] != nil {
74+ arg1 = args[1].(string)
75+ }
76+ run(
77+ arg0,
78+ arg1,
79+ )
80+ })
81+ return _c
82+}
83+
84+func (_c *mockhost_AppID_Call) Return() *mockhost_AppID_Call {
85+ _c.Call.Return()
86+ return _c
87+}
88+
89+func (_c *mockhost_AppID_Call) RunAndReturn(run func(slot Slot, appID string)) *mockhost_AppID_Call {
90+ _c.Run(run)
91+ return _c
92+}
93+
94+// Associate provides a mock function for the type mockhost
95+func (_mock *mockhost) Associate(slot Slot, surfaceID uint64) {
96+ _mock.Called(slot, surfaceID)
97+ return
98+}
99+
100+// mockhost_Associate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Associate'
101+type mockhost_Associate_Call struct {
102+ *mock.Call
103+}
104+
105+// Associate is a helper method to define mock.On call
106+// - slot Slot
107+// - surfaceID uint64
108+func (_e *mockhost_Expecter) Associate(slot any, surfaceID any) *mockhost_Associate_Call {
109+ return &mockhost_Associate_Call{Call: _e.mock.On("Associate", slot, surfaceID)}
110+}
111+
112+func (_c *mockhost_Associate_Call) Run(run func(slot Slot, surfaceID uint64)) *mockhost_Associate_Call {
113+ _c.Call.Run(func(args mock.Arguments) {
114+ var arg0 Slot
115+ if args[0] != nil {
116+ arg0 = args[0].(Slot)
117+ }
118+ var arg1 uint64
119+ if args[1] != nil {
120+ arg1 = args[1].(uint64)
121+ }
122+ run(
123+ arg0,
124+ arg1,
125+ )
126+ })
127+ return _c
128+}
129+
130+func (_c *mockhost_Associate_Call) Return() *mockhost_Associate_Call {
131+ _c.Call.Return()
132+ return _c
133+}
134+
135+func (_c *mockhost_Associate_Call) RunAndReturn(run func(slot Slot, surfaceID uint64)) *mockhost_Associate_Call {
136+ _c.Run(run)
137+ return _c
138+}
139+
140+// Created provides a mock function for the type mockhost
141+func (_mock *mockhost) Created(slot Slot, xid uint32) ports.WindowID {
142+ ret := _mock.Called(slot, xid)
143+
144+ if len(ret) == 0 {
145+ panic("no return value specified for Created")
146+ }
147+
148+ var r0 ports.WindowID
149+ if returnFunc, ok := ret.Get(0).(func(Slot, uint32) ports.WindowID); ok {
150+ r0 = returnFunc(slot, xid)
151+ } else {
152+ r0 = ret.Get(0).(ports.WindowID)
153+ }
154+ return r0
155+}
156+
157+// mockhost_Created_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Created'
158+type mockhost_Created_Call struct {
159+ *mock.Call
160+}
161+
162+// Created is a helper method to define mock.On call
163+// - slot Slot
164+// - xid uint32
165+func (_e *mockhost_Expecter) Created(slot any, xid any) *mockhost_Created_Call {
166+ return &mockhost_Created_Call{Call: _e.mock.On("Created", slot, xid)}
167+}
168+
169+func (_c *mockhost_Created_Call) Run(run func(slot Slot, xid uint32)) *mockhost_Created_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ var arg0 Slot
172+ if args[0] != nil {
173+ arg0 = args[0].(Slot)
174+ }
175+ var arg1 uint32
176+ if args[1] != nil {
177+ arg1 = args[1].(uint32)
178+ }
179+ run(
180+ arg0,
181+ arg1,
182+ )
183+ })
184+ return _c
185+}
186+
187+func (_c *mockhost_Created_Call) Return(windowID ports.WindowID) *mockhost_Created_Call {
188+ _c.Call.Return(windowID)
189+ return _c
190+}
191+
192+func (_c *mockhost_Created_Call) RunAndReturn(run func(slot Slot, xid uint32) ports.WindowID) *mockhost_Created_Call {
193+ _c.Call.Return(run)
194+ return _c
195+}
196+
197+// Dissociate provides a mock function for the type mockhost
198+func (_mock *mockhost) Dissociate(slot Slot, destroyed bool) {
199+ _mock.Called(slot, destroyed)
200+ return
201+}
202+
203+// mockhost_Dissociate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dissociate'
204+type mockhost_Dissociate_Call struct {
205+ *mock.Call
206+}
207+
208+// Dissociate is a helper method to define mock.On call
209+// - slot Slot
210+// - destroyed bool
211+func (_e *mockhost_Expecter) Dissociate(slot any, destroyed any) *mockhost_Dissociate_Call {
212+ return &mockhost_Dissociate_Call{Call: _e.mock.On("Dissociate", slot, destroyed)}
213+}
214+
215+func (_c *mockhost_Dissociate_Call) Run(run func(slot Slot, destroyed bool)) *mockhost_Dissociate_Call {
216+ _c.Call.Run(func(args mock.Arguments) {
217+ var arg0 Slot
218+ if args[0] != nil {
219+ arg0 = args[0].(Slot)
220+ }
221+ var arg1 bool
222+ if args[1] != nil {
223+ arg1 = args[1].(bool)
224+ }
225+ run(
226+ arg0,
227+ arg1,
228+ )
229+ })
230+ return _c
231+}
232+
233+func (_c *mockhost_Dissociate_Call) Return() *mockhost_Dissociate_Call {
234+ _c.Call.Return()
235+ return _c
236+}
237+
238+func (_c *mockhost_Dissociate_Call) RunAndReturn(run func(slot Slot, destroyed bool)) *mockhost_Dissociate_Call {
239+ _c.Run(run)
240+ return _c
241+}
242+
243+// Fullscreen provides a mock function for the type mockhost
244+func (_mock *mockhost) Fullscreen(slot Slot, on bool) {
245+ _mock.Called(slot, on)
246+ return
247+}
248+
249+// mockhost_Fullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fullscreen'
250+type mockhost_Fullscreen_Call struct {
251+ *mock.Call
252+}
253+
254+// Fullscreen is a helper method to define mock.On call
255+// - slot Slot
256+// - on bool
257+func (_e *mockhost_Expecter) Fullscreen(slot any, on any) *mockhost_Fullscreen_Call {
258+ return &mockhost_Fullscreen_Call{Call: _e.mock.On("Fullscreen", slot, on)}
259+}
260+
261+func (_c *mockhost_Fullscreen_Call) Run(run func(slot Slot, on bool)) *mockhost_Fullscreen_Call {
262+ _c.Call.Run(func(args mock.Arguments) {
263+ var arg0 Slot
264+ if args[0] != nil {
265+ arg0 = args[0].(Slot)
266+ }
267+ var arg1 bool
268+ if args[1] != nil {
269+ arg1 = args[1].(bool)
270+ }
271+ run(
272+ arg0,
273+ arg1,
274+ )
275+ })
276+ return _c
277+}
278+
279+func (_c *mockhost_Fullscreen_Call) Return() *mockhost_Fullscreen_Call {
280+ _c.Call.Return()
281+ return _c
282+}
283+
284+func (_c *mockhost_Fullscreen_Call) RunAndReturn(run func(slot Slot, on bool)) *mockhost_Fullscreen_Call {
285+ _c.Run(run)
286+ return _c
287+}
288+
289+// Parent provides a mock function for the type mockhost
290+func (_mock *mockhost) Parent(slot Slot, parent Slot) {
291+ _mock.Called(slot, parent)
292+ return
293+}
294+
295+// mockhost_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent'
296+type mockhost_Parent_Call struct {
297+ *mock.Call
298+}
299+
300+// Parent is a helper method to define mock.On call
301+// - slot Slot
302+// - parent Slot
303+func (_e *mockhost_Expecter) Parent(slot any, parent any) *mockhost_Parent_Call {
304+ return &mockhost_Parent_Call{Call: _e.mock.On("Parent", slot, parent)}
305+}
306+
307+func (_c *mockhost_Parent_Call) Run(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
308+ _c.Call.Run(func(args mock.Arguments) {
309+ var arg0 Slot
310+ if args[0] != nil {
311+ arg0 = args[0].(Slot)
312+ }
313+ var arg1 Slot
314+ if args[1] != nil {
315+ arg1 = args[1].(Slot)
316+ }
317+ run(
318+ arg0,
319+ arg1,
320+ )
321+ })
322+ return _c
323+}
324+
325+func (_c *mockhost_Parent_Call) Return() *mockhost_Parent_Call {
326+ _c.Call.Return()
327+ return _c
328+}
329+
330+func (_c *mockhost_Parent_Call) RunAndReturn(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
331+ _c.Run(run)
332+ return _c
333+}
334+
335+// Ready provides a mock function for the type mockhost
336+func (_mock *mockhost) Ready(slot Slot, info WindowInfo) {
337+ _mock.Called(slot, info)
338+ return
339+}
340+
341+// mockhost_Ready_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ready'
342+type mockhost_Ready_Call struct {
343+ *mock.Call
344+}
345+
346+// Ready is a helper method to define mock.On call
347+// - slot Slot
348+// - info WindowInfo
349+func (_e *mockhost_Expecter) Ready(slot any, info any) *mockhost_Ready_Call {
350+ return &mockhost_Ready_Call{Call: _e.mock.On("Ready", slot, info)}
351+}
352+
353+func (_c *mockhost_Ready_Call) Run(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
354+ _c.Call.Run(func(args mock.Arguments) {
355+ var arg0 Slot
356+ if args[0] != nil {
357+ arg0 = args[0].(Slot)
358+ }
359+ var arg1 WindowInfo
360+ if args[1] != nil {
361+ arg1 = args[1].(WindowInfo)
362+ }
363+ run(
364+ arg0,
365+ arg1,
366+ )
367+ })
368+ return _c
369+}
370+
371+func (_c *mockhost_Ready_Call) Return() *mockhost_Ready_Call {
372+ _c.Call.Return()
373+ return _c
374+}
375+
376+func (_c *mockhost_Ready_Call) RunAndReturn(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
377+ _c.Run(run)
378+ return _c
379+}
380+
381+// Title provides a mock function for the type mockhost
382+func (_mock *mockhost) Title(slot Slot, title string) {
383+ _mock.Called(slot, title)
384+ return
385+}
386+
387+// mockhost_Title_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Title'
388+type mockhost_Title_Call struct {
389+ *mock.Call
390+}
391+
392+// Title is a helper method to define mock.On call
393+// - slot Slot
394+// - title string
395+func (_e *mockhost_Expecter) Title(slot any, title any) *mockhost_Title_Call {
396+ return &mockhost_Title_Call{Call: _e.mock.On("Title", slot, title)}
397+}
398+
399+func (_c *mockhost_Title_Call) Run(run func(slot Slot, title string)) *mockhost_Title_Call {
400+ _c.Call.Run(func(args mock.Arguments) {
401+ var arg0 Slot
402+ if args[0] != nil {
403+ arg0 = args[0].(Slot)
404+ }
405+ var arg1 string
406+ if args[1] != nil {
407+ arg1 = args[1].(string)
408+ }
409+ run(
410+ arg0,
411+ arg1,
412+ )
413+ })
414+ return _c
415+}
416+
417+func (_c *mockhost_Title_Call) Return() *mockhost_Title_Call {
418+ _c.Call.Return()
419+ return _c
420+}
421+
422+func (_c *mockhost_Title_Call) RunAndReturn(run func(slot Slot, title string)) *mockhost_Title_Call {
423+ _c.Run(run)
424+ return _c
425+}
A internal/adapters/wayland/xwm/mapflow.go +337 -0
@@ -0,0 +1,337 @@
1+package xwm
2+
3+import (
4+ "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/xproto"
6+)
7+
8+// ICCCM WM_STATE values.
9+const (
10+ wmStateWithdrawn uint32 = 0
11+ wmStateNormal uint32 = 1
12+)
13+
14+// _NET_WM_STATE client message actions.
15+const (
16+ netStateRemove = 0
17+ netStateAdd = 1
18+ netStateToggle = 2
19+)
20+
21+// beginMap starts the map flow of a managed window: every property, the
22+// geometry and MapWindow go out in one batch (wlroots order). Also called by
23+// Attach for windows mapped before it.
24+func (x *XWM) beginMap(s Slot) {
25+ rec := x.tab.rec(s)
26+ if rec.kind != kindManaged || rec.mapping {
27+ return
28+ }
29+ rec.mapping, rec.ready = true, false
30+ rec.hasNetName = false
31+ rec.pending = propGeom
32+ xid := rec.xid
33+ for idx := 0; idx < propCount; idx++ {
34+ x.requestProp(s, idx)
35+ }
36+ b := x.conn.Buf()
37+ x.expect(xproto.GetGeometry(b, xproto.Drawable(xid)), kindGeom, x.tab.tag(s), 0)
38+ xproto.MapWindow(b, xproto.Window(xid))
39+}
40+
41+func (x *XWM) onMapRequest(ev *xturbo.Event) {
42+ if s := x.tab.lookup(uint32(xproto.MapRequestOf(ev).Window())); s != 0 {
43+ x.beginMap(s)
44+ }
45+}
46+
47+func (x *XWM) onMapNotify(ev *xturbo.Event) {
48+ if s := x.tab.lookup(uint32(xproto.MapNotifyOf(ev).Window())); s != 0 {
49+ x.tab.rec(s).mapped = true
50+ }
51+}
52+
53+// onUnmapNotify ends the mapped life of a managed window; a later MapRequest
54+// starts over and re-reads the properties. Synthetic events (ICCCM unmap
55+// requests) are not an unmap.
56+func (x *XWM) onUnmapNotify(ev *xturbo.Event) {
57+ if ev.SendEvent {
58+ return
59+ }
60+ s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window()))
61+ if s == 0 {
62+ return
63+ }
64+ rec := x.tab.rec(s)
65+ rec.mapped = false
66+ if rec.kind != kindManaged {
67+ return
68+ }
69+ wasMapping := rec.mapping
70+ rec.ready, rec.mapping, rec.pending, rec.sized = false, false, 0, false
71+ rec.gen++ // replies still in flight for this map name a dead tag
72+ if rec.gen == 0 {
73+ rec.gen = 1
74+ }
75+ rec.pend.dirty = false
76+ rec.serial, rec.surface = 0, 0
77+ if wasMapping {
78+ xid := rec.xid
79+ x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateWithdrawn, 0})
80+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(xid), xproto.Atom(x.atoms[atomNetWMState]))
81+ }
82+ x.dropWindow(s)
83+ if rec.id != 0 {
84+ x.host.Dissociate(s, false)
85+ }
86+}
87+
88+// setProp32Typed is setProp32 with an explicit property type atom.
89+func (x *XWM) setProp32Typed(win uint32, prop int, typ uint32, vals []uint32) {
90+ x.setProp32(win, prop, xproto.Atom(typ), vals)
91+}
92+
93+// checkReady fires Ready once every awaited reply is in.
94+func (x *XWM) checkReady(s Slot) {
95+ rec := x.tab.rec(s)
96+ if rec == nil || rec.pending != 0 || rec.ready || !rec.mapping || rec.kind != kindManaged || x.host == nil {
97+ return
98+ }
99+ rec.ready = true
100+ x.classify(rec)
101+ info := x.info(s)
102+ xid := rec.xid
103+
104+ var state [2]uint32
105+ n := 0
106+ if rec.modal {
107+ state[n] = x.atoms[atomNetWMStateModal]
108+ n++
109+ }
110+ if rec.fullscreen {
111+ state[n] = x.atoms[atomNetWMStateFullscreen]
112+ n++
113+ }
114+ x.setProp32(xid, atomNetWMState, xproto.AtomAtom, state[:n])
115+ x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateNormal, 0})
116+ x.addClient(xid)
117+
118+ x.host.Ready(s, info)
119+ x.tryPair(s)
120+}
121+
122+// info is what the host learns when a window is ready.
123+func (x *XWM) info(s Slot) WindowInfo {
124+ rec := x.tab.rec(s)
125+ parent := x.managedSlot(rec.transient)
126+ info := WindowInfo{
127+ PID: int(rec.pid),
128+ Floating: rec.floating,
129+ Parent: parent,
130+ Modal: rec.modal,
131+ Fullscreen: rec.fullscreen,
132+ Title: rec.title,
133+ AppID: rec.class,
134+ }
135+ if info.Floating || rec.sized {
136+ info.Width, info.Height = int(rec.w), int(rec.h)
137+ }
138+ return info
139+}
140+
141+func (x *XWM) onGeomReply(s Slot, rec *record, r *xturbo.Reply) {
142+ if g, ok := xproto.ParseGetGeometryReply(r.Raw); ok {
143+ rec.x, rec.y, rec.w, rec.h = int32(g.X()), int32(g.Y()), uint32(g.Width()), uint32(g.Height())
144+ }
145+ rec.pending &^= propGeom
146+ x.checkReady(s)
147+}
148+
149+// onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy
150+// WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.
151+func (x *XWM) onClientMessage(ev *xturbo.Event) {
152+ e := xproto.ClientMessageOf(ev)
153+ if e.Format() != 32 {
154+ return
155+ }
156+ typ := uint32(e.Type())
157+ switch typ {
158+ case x.atoms[atomWLSurfaceSerial]:
159+ s := x.tab.lookup(uint32(e.Window()))
160+ if s == 0 {
161+ return
162+ }
163+ rec := x.tab.rec(s)
164+ if rec.serial != 0 {
165+ x.log.Debug().Uint32("xid", rec.xid).Msg("duplicate WL_SURFACE_SERIAL ignored")
166+ return
167+ }
168+ d := e.Data().Data32()
169+ rec.serial = uint64(d[1])<<32 | uint64(d[0])
170+ x.tryPair(s)
171+ case x.atoms[atomWLSurfaceID]:
172+ x.stats.LegacyID++
173+ if x.stats.LegacyID == 1 {
174+ x.log.Warn().Msg("WL_SURFACE_ID received: Xwayland did not bind xwayland_shell_v1, window ignored")
175+ }
176+ case x.atoms[atomNetWMState]:
177+ x.onNetWMState(e)
178+ }
179+}
180+
181+// onNetWMState forwards fullscreen requests to the host and records them; the
182+// property itself is written when core answers with a configure.
183+func (x *XWM) onNetWMState(e xproto.ClientMessageEvent) {
184+ s := x.tab.lookup(uint32(e.Window()))
185+ if s == 0 {
186+ return
187+ }
188+ rec := x.tab.rec(s)
189+ if rec.kind != kindManaged {
190+ return
191+ }
192+ d := e.Data().Data32()
193+ apply := func(cur bool) bool {
194+ switch d[0] {
195+ case netStateRemove:
196+ return false
197+ case netStateAdd:
198+ return true
199+ case netStateToggle:
200+ return !cur
201+ }
202+ return cur
203+ }
204+ for _, a := range d[1:3] {
205+ switch {
206+ case a == 0:
207+ case a == x.atoms[atomNetWMStateModal]:
208+ rec.modal = apply(rec.modal)
209+ case a == x.atoms[atomNetWMStateFullscreen]:
210+ on := apply(rec.fullscreen)
211+ if on == rec.fullscreen {
212+ continue
213+ }
214+ rec.fullscreen = on
215+ if rec.ready {
216+ x.host.Fullscreen(s, on)
217+ rec = x.tab.rec(s)
218+ }
219+ }
220+ }
221+}
222+
223+// onReparentNotify keeps the table to root's direct children: a window moved
224+// away is forgotten, one moved into root is treated as newly created.
225+func (x *XWM) onReparentNotify(ev *xturbo.Event) {
226+ e := xproto.ReparentNotifyOf(ev)
227+ xid := uint32(e.Window())
228+ if xid == x.wmWin {
229+ return
230+ }
231+ s := x.tab.lookup(xid)
232+ if uint32(e.Parent()) != x.root {
233+ if s != 0 {
234+ x.forgetWindow(s)
235+ }
236+ return
237+ }
238+ if s != 0 {
239+ return
240+ }
241+ s = x.insert(xid)
242+ if s == 0 {
243+ return
244+ }
245+ rec := x.tab.rec(s)
246+ rec.override = e.OverrideRedirect()
247+ rec.x, rec.y = int32(e.X()), int32(e.Y())
248+ if rec.override {
249+ rec.kind = kindUnmanaged
250+ x.stats.UnmanagedSeen++
251+ return
252+ }
253+ if x.host != nil {
254+ x.adopt(s)
255+ }
256+}
257+
258+// tryPair links the window to the wl_surface carrying its serial, once both
259+// are known and the window is ready (the host maps on the surface's first
260+// buffer, so it must know the window's info by then). Unmanaged windows keep
261+// the surface but are never associated.
262+func (x *XWM) tryPair(s Slot) {
263+ rec := x.tab.rec(s)
264+ if rec.serial == 0 || rec.surface != 0 {
265+ return
266+ }
267+ if rec.kind == kindManaged && !rec.ready {
268+ return
269+ }
270+ for i := 0; i < x.unpairedN; i++ {
271+ if x.unpaired[i].serial != rec.serial {
272+ continue
273+ }
274+ rec.surface = x.unpaired[i].surface
275+ x.unpairedN--
276+ x.unpaired[i] = x.unpaired[x.unpairedN]
277+ if rec.kind == kindManaged {
278+ x.log.Debug().Uint32("xid", rec.xid).Uint64("surface", rec.surface).Msg("x11 window paired")
279+ x.host.Associate(s, rec.surface)
280+ }
281+ return
282+ }
283+}
284+
285+// SetSerial records the serial of a wl_surface (xwayland_surface_v1.set_serial)
286+// and pairs it with the window announcing the same serial.
287+func (x *XWM) SetSerial(surfaceID uint64, serial uint64) {
288+ if serial == 0 || x.closed {
289+ return
290+ }
291+ for i := 0; i < x.unpairedN; i++ {
292+ if x.unpaired[i].serial == serial {
293+ x.unpaired[i].surface = surfaceID
294+ x.pairSerial(serial)
295+ return
296+ }
297+ }
298+ if x.unpairedN == unpairedCap {
299+ copy(x.unpaired[:], x.unpaired[1:])
300+ x.unpairedN--
301+ if !x.warnedUnpaired {
302+ x.warnedUnpaired = true
303+ x.log.Warn().Msg("too many unpaired surfaces, dropping the oldest")
304+ }
305+ }
306+ x.unpaired[x.unpairedN] = struct{ serial, surface uint64 }{serial, surfaceID}
307+ x.unpairedN++
308+ x.pairSerial(serial)
309+}
310+
311+// pairSerial offers a newly known surface serial to the windows.
312+func (x *XWM) pairSerial(serial uint64) {
313+ for i := range x.tab.recs {
314+ if r := &x.tab.recs[i]; r.kind != kindFree && r.serial == serial && r.surface == 0 {
315+ x.tryPair(Slot(i + 1))
316+ break
317+ }
318+ }
319+ x.wake()
320+}
321+
322+// SurfaceGone forgets a destroyed wl_surface.
323+func (x *XWM) SurfaceGone(surfaceID uint64) {
324+ for i := 0; i < x.unpairedN; i++ {
325+ if x.unpaired[i].surface == surfaceID {
326+ x.unpairedN--
327+ x.unpaired[i] = x.unpaired[x.unpairedN]
328+ break
329+ }
330+ }
331+ for i := range x.tab.recs {
332+ if r := &x.tab.recs[i]; r.kind != kindFree && r.surface == surfaceID {
333+ r.surface = 0
334+ }
335+ }
336+ x.wake()
337+}
A internal/adapters/wayland/xwm/mapflow_test.go +779 -0
@@ -0,0 +1,779 @@
1+package xwm
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+
8+ "bnema.dev/xturbo"
9+ "bnema.dev/xturbo/xproto"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// harness bundles an attached XWM, its peer and the host mock.
16+type harness struct {
17+ t *testing.T
18+ x *XWM
19+ p *xpeer
20+ h *mockhost
21+
22+ ready map[uint32]WindowInfo // by xid, filled by Ready
23+ next ports.WindowID
24+}
25+
26+func newHarness(t *testing.T, mutate func(p *xpeer)) *harness {
27+ t.Helper()
28+ x, p := bootPeer(t, mutate)
29+ hs := &harness{t: t, x: x, p: p, h: newMockhost(t), ready: map[uint32]WindowInfo{}}
30+ src := newMockfdSource(t)
31+ src.EXPECT().Remove().Maybe()
32+ src.EXPECT().SetWritable(mock.Anything).Return(nil).Maybe()
33+ post, _ := newPost()
34+ x.attach(src, hs.h, post)
35+ return hs
36+}
37+
38+func (hs *harness) expectCreated(win uint32) {
39+ hs.next++
40+ hs.h.EXPECT().Created(mock.Anything, win).Return(hs.next).Once()
41+}
42+
43+func (hs *harness) expectReady(win uint32) {
44+ hs.h.EXPECT().Ready(mock.Anything, mock.Anything).Run(func(_ Slot, info WindowInfo) {
45+ hs.ready[win] = info
46+ }).Once()
47+}
48+
49+func (hs *harness) create(win uint32) Slot {
50+ hs.t.Helper()
51+ hs.expectCreated(win)
52+ hs.p.createNotify(win, false)
53+ readUntil(hs.t, hs.x, func() bool { return hs.x.tab.lookup(win) != 0 })
54+ return hs.x.tab.lookup(win)
55+}
56+
57+// mapAndWait creates and maps win and waits for Ready.
58+func (hs *harness) mapAndWait(win uint32) Slot {
59+ hs.t.Helper()
60+ s := hs.create(win)
61+ hs.expectReady(win)
62+ hs.p.mapRequest(win)
63+ readUntil(hs.t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
64+ return s
65+}
66+
67+func (hs *harness) str(win uint32, atom string, typ uint32, s string) {
68+ hs.p.setProp(win, hs.p.atom(atom), typ, 8, []byte(s))
69+}
70+
71+func (hs *harness) words(win uint32, atom string, typ uint32, w ...uint32) {
72+ hs.p.setProp(win, hs.p.atom(atom), typ, 32, le32(w...))
73+}
74+
75+func (hs *harness) atoms(win uint32, atom string, names ...string) {
76+ var w []uint32
77+ for _, n := range names {
78+ w = append(w, hs.p.atom(n))
79+ }
80+ hs.words(win, atom, uint32(xproto.AtomAtom), w...)
81+}
82+
83+func (hs *harness) classic(win uint32) {
84+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("inst\x00Class\x00"))
85+ hs.str(win, "_NET_WM_NAME", hs.p.atom("UTF8_STRING"), "net title")
86+ hs.p.setProp(win, uint32(xproto.AtomWmName), uint32(xproto.AtomString), 8, []byte("old title"))
87+ hs.words(win, "_NET_WM_PID", uint32(xproto.AtomCardinal), 4242)
88+ hs.p.set(func(p *xpeer) { p.geoms[win] = xpeerGeom{w: 300, h: 200} })
89+}
90+
91+func (hs *harness) count(match func(r xpeerReq) bool) int {
92+ n := 0
93+ for _, r := range hs.p.requests() {
94+ if match(r) {
95+ n++
96+ }
97+ }
98+ return n
99+}
100+
101+func (hs *harness) waitMap(win uint32) {
102+ hs.t.Helper()
103+ readUntil(hs.t, hs.x, func() bool {
104+ return hs.count(func(r xpeerReq) bool { return r.major == opMapWindow && r.u32(4) == win }) > 0
105+ })
106+}
107+
108+func TestMapFlowBatchesRequestsAndReadyFiresOnce(t *testing.T) {
109+ hs := newHarness(t, nil)
110+ const win = 0x500001
111+ hs.classic(win)
112+ hs.create(win)
113+ base := len(hs.p.requests())
114+
115+ hs.p.holdReplies()
116+ hs.p.mapRequest(win)
117+ hs.expectReady(win)
118+ hs.waitMap(win)
119+
120+ reqs := hs.p.requests()[base:]
121+ want := []uint32{
122+ uint32(xproto.AtomWmClass), uint32(xproto.AtomWmName), hs.p.atom("_NET_WM_NAME"), uint32(xproto.AtomWmHints),
123+ uint32(xproto.AtomWmNormalHints), hs.p.atom("WM_PROTOCOLS"), uint32(xproto.AtomWmTransientFor),
124+ hs.p.atom("_NET_WM_WINDOW_TYPE"), hs.p.atom("_NET_WM_STATE"), hs.p.atom("_NET_WM_PID"),
125+ }
126+ require.GreaterOrEqual(t, len(reqs), len(want)+2)
127+ for i, a := range want {
128+ require.EqualValues(t, opGetProperty, reqs[i].major, i)
129+ require.Equal(t, uint32(win), reqs[i].u32(4))
130+ require.Equal(t, a, reqs[i].u32(8), i)
131+ require.EqualValues(t, 1024, reqs[i].u32(20), "long length: one 4 KiB read")
132+ }
133+ require.EqualValues(t, opGetGeometry, reqs[len(want)].major)
134+ require.EqualValues(t, opMapWindow, reqs[len(want)+1].major, "MapWindow in the same batch")
135+
136+ hs.p.release() // every reply arrives after MapWindow went out
137+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
138+ info := hs.ready[win]
139+ require.Equal(t, WindowInfo{PID: 4242, Title: "net title", AppID: "Class"}, info, "_NET_WM_NAME wins, class is the second string")
140+
141+ state, ok := hs.p.prop(win, "WM_STATE")
142+ require.True(t, ok)
143+ require.Equal(t, []uint32{1, 0}, atomsOf(state))
144+ list, ok := hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
145+ require.True(t, ok)
146+ require.Equal(t, []uint32{win}, atomsOf(list))
147+}
148+
149+func TestMapFlowFloatingAndModal(t *testing.T) {
150+ hint := func(minw, minh, maxw, maxh uint32) []byte {
151+ return le32(hintMinSize|hintMaxSize, 0, 0, 0, 0, minw, minh, maxw, maxh, 0, 0, 0, 0, 0, 0, 0, 0, 0)
152+ }
153+ cases := []struct {
154+ name string
155+ setup func(hs *harness, win uint32)
156+ want func(info WindowInfo)
157+ }{
158+ {"dialog type", func(hs *harness, w uint32) {
159+ hs.atoms(w, "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DIALOG")
160+ }, func(i WindowInfo) {
161+ require.True(t, i.Floating)
162+ require.Equal(t, 300, i.Width)
163+ require.Equal(t, 200, i.Height)
164+ }},
165+ {"utility type", func(hs *harness, w uint32) {
166+ hs.atoms(w, "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_UTILITY")
167+ }, func(i WindowInfo) { require.True(t, i.Floating) }},
168+ {"splash type", func(hs *harness, w uint32) {
169+ hs.atoms(w, "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_SPLASH")
170+ }, func(i WindowInfo) { require.True(t, i.Floating) }},
171+ {"normal type", func(hs *harness, w uint32) {
172+ hs.atoms(w, "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_NORMAL")
173+ }, func(i WindowInfo) { require.False(t, i.Floating); require.Zero(t, i.Width) }},
174+ {"fixed size", func(hs *harness, w uint32) {
175+ hs.p.setProp(w, uint32(xproto.AtomWmNormalHints), uint32(xproto.AtomWmSizeHints), 32, hint(200, 100, 200, 100))
176+ }, func(i WindowInfo) { require.True(t, i.Floating) }},
177+ {"resizable", func(hs *harness, w uint32) {
178+ hs.p.setProp(w, uint32(xproto.AtomWmNormalHints), uint32(xproto.AtomWmSizeHints), 32, hint(100, 100, 800, 600))
179+ }, func(i WindowInfo) { require.False(t, i.Floating) }},
180+ {"modal", func(hs *harness, w uint32) {
181+ hs.atoms(w, "_NET_WM_STATE", "_NET_WM_STATE_MODAL")
182+ }, func(i WindowInfo) { require.True(t, i.Modal); require.False(t, i.Fullscreen) }},
183+ {"fullscreen at map", func(hs *harness, w uint32) {
184+ hs.atoms(w, "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN")
185+ }, func(i WindowInfo) { require.True(t, i.Fullscreen) }},
186+ }
187+ for _, c := range cases {
188+ t.Run(c.name, func(t *testing.T) {
189+ hs := newHarness(t, nil)
190+ const win = 0x500001
191+ hs.classic(win)
192+ c.setup(hs, win)
193+ hs.mapAndWait(win)
194+ c.want(hs.ready[win])
195+ })
196+ }
197+}
198+
199+func TestMapFlowTransientForManagedWindowFloats(t *testing.T) {
200+ hs := newHarness(t, nil)
201+ const parent, child = 0x500001, 0x500002
202+ hs.classic(parent)
203+ ps := hs.mapAndWait(parent)
204+ hs.classic(child)
205+ hs.p.setProp(child, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(parent))
206+ hs.mapAndWait(child)
207+ require.True(t, hs.ready[child].Floating)
208+ require.Equal(t, ps, hs.ready[child].Parent)
209+}
210+
211+func TestMapFlowTransientForUnknownWindowIsNotAParent(t *testing.T) {
212+ hs := newHarness(t, nil)
213+ const win = 0x500001
214+ hs.classic(win)
215+ hs.p.setProp(win, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(0x777777))
216+ hs.mapAndWait(win)
217+ require.Zero(t, hs.ready[win].Parent)
218+ require.False(t, hs.ready[win].Floating)
219+}
220+
221+func TestMapFlowClassFallsBackToInstance(t *testing.T) {
222+ hs := newHarness(t, nil)
223+ const win = 0x500001
224+ hs.classic(win)
225+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("onlyinstance\x00"))
226+ hs.mapAndWait(win)
227+ require.Equal(t, "onlyinstance", hs.ready[win].AppID)
228+}
229+
230+func TestMapFlowTitleFallsBackToWMName(t *testing.T) {
231+ hs := newHarness(t, nil)
232+ const win = 0x500001
233+ hs.classic(win)
234+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_NAME"]) })
235+ hs.mapAndWait(win)
236+ require.Equal(t, "old title", hs.ready[win].Title)
237+}
238+
239+func TestMapFlowPIDFromXRes(t *testing.T) {
240+ hs := newHarness(t, func(p *xpeer) { p.pids = map[uint32]uint32{0x500001: 777} })
241+ const win = 0x500001
242+ hs.classic(win)
243+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
244+ hs.create(win)
245+ hs.expectReady(win)
246+ hs.p.mapRequest(win)
247+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
248+ require.Equal(t, 777, hs.ready[win].PID, "Ready waited for the QueryClientIds reply")
249+ require.Equal(t, 1, hs.count(func(r xpeerReq) bool { return r.major == 144 && r.minor == 4 }))
250+ require.Zero(t, hs.x.tab.rec(hs.x.tab.lookup(win)).pending)
251+}
252+
253+func TestMapFlowPIDUnknownStillReady(t *testing.T) {
254+ hs := newHarness(t, nil)
255+ const win = 0x500001
256+ hs.classic(win)
257+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
258+ hs.mapAndWait(win)
259+ require.Zero(t, hs.ready[win].PID)
260+}
261+
262+func TestMapFlowNoXResAsksNothing(t *testing.T) {
263+ exts := fullPeerExts()
264+ delete(exts, "X-Resource")
265+ fd, p := startXPeer(t, exts)
266+ x, err := Boot(context.Background(), fd, testLog())
267+ require.NoError(t, err)
268+ t.Cleanup(x.CloseConn)
269+ h := newMockhost(t)
270+ src := newMockfdSource(t)
271+ src.EXPECT().Remove().Maybe()
272+ post, _ := newPost()
273+ x.attach(src, h, post)
274+ hs := &harness{t: t, x: x, p: p, h: h, ready: map[uint32]WindowInfo{}}
275+ const win = 0x500001
276+ hs.classic(win)
277+ p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
278+ hs.mapAndWait(win)
279+ require.Zero(t, hs.count(func(r xpeerReq) bool { return r.major == 144 }))
280+}
281+
282+func TestMapFlowBadWindowOnPropertiesNeverReady(t *testing.T) {
283+ hs := newHarness(t, nil)
284+ const win = 0x500001
285+ hs.classic(win)
286+ hs.p.set(func(p *xpeer) { p.bad[win] = true })
287+ s := hs.create(win)
288+ hs.p.mapRequest(win)
289+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Errors >= uint64(propCount)+1 })
290+ require.Zero(t, hs.x.tab.rec(s).pending, "failed replies are no longer awaited")
291+ require.False(t, hs.x.tab.rec(s).ready)
292+ require.Zero(t, hs.x.Stats().Unattributed)
293+
294+ hs.h.EXPECT().Dissociate(s, true).Once()
295+ hs.p.destroyNotify(win)
296+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
297+}
298+
299+func TestMapRequestTwiceBeforeReadyAsksOnce(t *testing.T) {
300+ hs := newHarness(t, nil)
301+ const win = 0x500001
302+ hs.classic(win)
303+ hs.create(win)
304+ hs.p.holdReplies()
305+ hs.p.mapRequest(win)
306+ hs.p.mapRequest(win)
307+ hs.expectReady(win)
308+ hs.waitMap(win)
309+ hs.p.release()
310+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
311+ require.Equal(t, 1, hs.count(func(r xpeerReq) bool { return r.major == opMapWindow && r.u32(4) == win }))
312+}
313+
314+func TestMapNotifyMarksMapped(t *testing.T) {
315+ hs := newHarness(t, nil)
316+ const win = 0x500001
317+ hs.classic(win)
318+ s := hs.mapAndWait(win)
319+ hs.p.mapNotify(win)
320+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).mapped })
321+}
322+
323+// Property changes after Ready.
324+
325+func (hs *harness) notify(win uint32, atom string, deleted bool) {
326+ hs.t.Helper()
327+ before := hs.x.Stats().Replies
328+ hs.p.propertyNotify(win, hs.p.atom(atom), deleted)
329+ if deleted {
330+ hs.p.propertyNotify(win, hs.p.atom("WM_PROTOCOLS"), false) // sentinel: one reply follows
331+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
332+ return
333+ }
334+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
335+}
336+
337+func TestPropertyChangedTitleReachesHostOnce(t *testing.T) {
338+ hs := newHarness(t, nil)
339+ const win = 0x500001
340+ hs.classic(win)
341+ s := hs.mapAndWait(win)
342+
343+ hs.h.EXPECT().Title(s, "renamed").Once()
344+ hs.str(win, "_NET_WM_NAME", hs.p.atom("UTF8_STRING"), "renamed")
345+ hs.notify(win, "_NET_WM_NAME", false)
346+ require.Equal(t, "renamed", hs.x.tab.rec(s).title)
347+
348+ // Same value again: no host call (the mock fails on an unexpected one).
349+ hs.notify(win, "_NET_WM_NAME", false)
350+
351+ // WM_NAME is ignored while _NET_WM_NAME is set.
352+ hs.p.setProp(win, uint32(xproto.AtomWmName), uint32(xproto.AtomString), 8, []byte("legacy"))
353+ before := hs.x.Stats().Events
354+ hs.p.propertyNotify(win, uint32(xproto.AtomWmName), false)
355+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events > before })
356+ require.Equal(t, "renamed", hs.x.tab.rec(s).title)
357+}
358+
359+func TestPropertyNetNameDeletedFallsBackToWMName(t *testing.T) {
360+ hs := newHarness(t, nil)
361+ const win = 0x500001
362+ hs.classic(win)
363+ s := hs.mapAndWait(win)
364+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_NAME"]) })
365+ hs.h.EXPECT().Title(s, "old title").Once()
366+ hs.p.propertyNotify(win, hs.p.atom("_NET_WM_NAME"), true)
367+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).title == "old title" })
368+}
369+
370+func TestPropertyClassChangeReachesHost(t *testing.T) {
371+ hs := newHarness(t, nil)
372+ const win = 0x500001
373+ hs.classic(win)
374+ s := hs.mapAndWait(win)
375+ hs.h.EXPECT().AppID(s, "Other").Once()
376+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("i\x00Other\x00"))
377+ hs.p.propertyNotify(win, uint32(xproto.AtomWmClass), false)
378+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).class == "Other" })
379+}
380+
381+func TestPropertyTransientChangeReachesHost(t *testing.T) {
382+ hs := newHarness(t, nil)
383+ const a, b = 0x500001, 0x500002
384+ hs.classic(a)
385+ as := hs.mapAndWait(a)
386+ hs.classic(b)
387+ bs := hs.mapAndWait(b)
388+ hs.h.EXPECT().Parent(bs, as).Once()
389+ hs.p.setProp(b, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(a))
390+ hs.p.propertyNotify(b, uint32(xproto.AtomWmTransientFor), false)
391+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(bs).transient == a })
392+}
393+
394+func TestPropertyInputProtocolsAndHints(t *testing.T) {
395+ hs := newHarness(t, nil)
396+ const win = 0x500001
397+ hs.classic(win)
398+ hs.atoms(win, "WM_PROTOCOLS", "WM_DELETE_WINDOW", "WM_TAKE_FOCUS")
399+ hs.p.setProp(win, uint32(xproto.AtomWmHints), uint32(xproto.AtomWmHints), 32, le32(hintInput, 0, 0, 0, 0, 0, 0, 0, 0))
400+ s := hs.mapAndWait(win)
401+ r := hs.x.tab.rec(s)
402+ require.True(t, r.deleteWindow)
403+ require.True(t, r.takeFocus)
404+ require.False(t, r.input, "WM_HINTS input = false")
405+
406+ // No WM_HINTS: input defaults to true.
407+ const other = 0x500002
408+ hs.classic(other)
409+ o := hs.mapAndWait(other)
410+ require.True(t, hs.x.tab.rec(o).input)
411+ require.False(t, hs.x.tab.rec(o).deleteWindow)
412+}
413+
414+func TestPropertyUnwatchedAtomAsksNothing(t *testing.T) {
415+ hs := newHarness(t, nil)
416+ const win = 0x500001
417+ hs.classic(win)
418+ hs.mapAndWait(win)
419+ n := len(hs.p.requests())
420+ hs.p.propertyNotify(win, hs.p.atom("_MOTIF_WM_HINTS"), false)
421+ hs.p.propertyNotify(0x999999, hs.p.atom("_NET_WM_NAME"), false) // unknown window
422+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
423+ require.Len(t, hs.p.requests(), n)
424+}
425+
426+// Pairing.
427+
428+func (hs *harness) serialMsg(win uint32, serial uint64) {
429+ hs.p.clientMessage(win, hs.p.atom("WL_SURFACE_SERIAL"), uint32(serial), uint32(serial>>32))
430+ hs.t.Helper()
431+ before := hs.x.Stats().Events
432+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Events > before })
433+}
434+
435+func TestPairingSerialBeforeSetSerial(t *testing.T) {
436+ hs := newHarness(t, nil)
437+ const win = 0x500001
438+ hs.classic(win)
439+ s := hs.mapAndWait(win)
440+ hs.serialMsg(win, 0x1_0000_0002)
441+ require.EqualValues(t, 0x1_0000_0002, hs.x.tab.rec(s).serial, "high word in data32[1]")
442+ hs.h.EXPECT().Associate(s, uint64(99)).Once()
443+ hs.x.SetSerial(99, 0x1_0000_0002)
444+ require.EqualValues(t, 99, hs.x.tab.rec(s).surface)
445+ require.Zero(t, hs.x.unpairedN)
446+}
447+
448+func TestPairingSetSerialBeforeSerial(t *testing.T) {
449+ hs := newHarness(t, nil)
450+ const win = 0x500001
451+ hs.classic(win)
452+ s := hs.mapAndWait(win)
453+ hs.x.SetSerial(99, 7)
454+ require.Equal(t, 1, hs.x.unpairedN)
455+ hs.h.EXPECT().Associate(s, uint64(99)).Once()
456+ hs.serialMsg(win, 7)
457+ require.Zero(t, hs.x.unpairedN)
458+}
459+
460+func TestPairingWaitsForReady(t *testing.T) {
461+ hs := newHarness(t, nil)
462+ const win = 0x500001
463+ hs.classic(win)
464+ s := hs.create(win)
465+ hs.x.SetSerial(5, 3)
466+ hs.serialMsg(win, 3) // not mapped yet: no Associate (the mock fails on one)
467+ require.Zero(t, hs.x.tab.rec(s).surface)
468+
469+ hs.h.EXPECT().Associate(s, uint64(5)).Once()
470+ hs.expectReady(win)
471+ hs.p.mapRequest(win)
472+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).surface != 0 })
473+}
474+
475+func TestPairingSerialForUnknownWindowIgnored(t *testing.T) {
476+ hs := newHarness(t, nil)
477+ hs.x.SetSerial(5, 3)
478+ hs.serialMsg(0x888888, 3)
479+ require.Equal(t, 1, hs.x.unpairedN, "stays unpaired")
480+}
481+
482+func TestPairingSurfaceGoneBeforePair(t *testing.T) {
483+ hs := newHarness(t, nil)
484+ const win = 0x500001
485+ hs.classic(win)
486+ s := hs.mapAndWait(win)
487+ hs.x.SetSerial(5, 3)
488+ hs.x.SurfaceGone(5)
489+ require.Zero(t, hs.x.unpairedN)
490+ hs.serialMsg(win, 3) // the surface is gone: nothing to pair with
491+ require.Zero(t, hs.x.tab.rec(s).surface)
492+}
493+
494+func TestPairingSurfaceGoneAfterPairClearsLink(t *testing.T) {
495+ hs := newHarness(t, nil)
496+ const win = 0x500001
497+ hs.classic(win)
498+ s := hs.mapAndWait(win)
499+ hs.serialMsg(win, 3)
500+ hs.h.EXPECT().Associate(s, uint64(5)).Once()
501+ hs.x.SetSerial(5, 3)
502+ hs.x.SurfaceGone(5)
503+ require.Zero(t, hs.x.tab.rec(s).surface)
504+}
505+
506+func TestPairingDuplicateSerialIgnored(t *testing.T) {
507+ hs := newHarness(t, nil)
508+ const win = 0x500001
509+ hs.classic(win)
510+ s := hs.mapAndWait(win)
511+ hs.serialMsg(win, 3)
512+ hs.serialMsg(win, 4)
513+ require.EqualValues(t, 3, hs.x.tab.rec(s).serial)
514+ hs.x.SetSerial(8, 4) // nobody announces 4: stays unpaired
515+ require.Equal(t, 1, hs.x.unpairedN)
516+}
517+
518+func TestPairingUnmanagedKeepsSurfaceWithoutAssociate(t *testing.T) {
519+ hs := newHarness(t, nil)
520+ hs.p.createNotify(0x500009, true)
521+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
522+ hs.serialMsg(0x500009, 3)
523+ hs.x.SetSerial(5, 3) // the mock fails on an Associate
524+ require.EqualValues(t, 5, hs.x.tab.rec(hs.x.tab.lookup(0x500009)).surface)
525+}
526+
527+func TestPairingTableOverflowDropsOldest(t *testing.T) {
528+ hs := newHarness(t, nil)
529+ for i := uint64(1); i <= unpairedCap+1; i++ {
530+ hs.x.SetSerial(100+i, i)
531+ }
532+ require.Equal(t, unpairedCap, hs.x.unpairedN)
533+ for i := 0; i < hs.x.unpairedN; i++ {
534+ require.NotEqual(t, uint64(1), hs.x.unpaired[i].serial, "oldest dropped")
535+ }
536+}
537+
538+func TestPairingZeroSerialIgnored(t *testing.T) {
539+ hs := newHarness(t, nil)
540+ hs.x.SetSerial(5, 0)
541+ require.Zero(t, hs.x.unpairedN)
542+}
543+
544+func TestLegacySurfaceIDCountedAndIgnored(t *testing.T) {
545+ hs := newHarness(t, nil)
546+ const win = 0x500001
547+ hs.classic(win)
548+ s := hs.mapAndWait(win)
549+ hs.p.clientMessage(win, hs.p.atom("WL_SURFACE_ID"), 12)
550+ readUntil(t, hs.x, func() bool { return hs.x.Stats().LegacyID == 1 })
551+ require.Zero(t, hs.x.tab.rec(s).serial)
552+}
553+
554+func TestNetWMStateClientMessageForwardsFullscreen(t *testing.T) {
555+ hs := newHarness(t, nil)
556+ const win = 0x500001
557+ hs.classic(win)
558+ s := hs.mapAndWait(win)
559+ hs.h.EXPECT().Fullscreen(s, true).Once()
560+ hs.p.clientMessage(win, hs.p.atom("_NET_WM_STATE"), 1, hs.p.atom("_NET_WM_STATE_FULLSCREEN"))
561+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).fullscreen })
562+ hs.h.EXPECT().Fullscreen(s, false).Once() // toggle
563+ hs.p.clientMessage(win, hs.p.atom("_NET_WM_STATE"), 2, hs.p.atom("_NET_WM_STATE_FULLSCREEN"))
564+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).fullscreen })
565+ // Not written back by the message itself.
566+ st, ok := hs.p.prop(win, "_NET_WM_STATE")
567+ require.True(t, ok)
568+ require.Empty(t, st.data)
569+}
570+
571+// Unmap, destroy, remap.
572+
573+func TestUnmapDissociatesAndRemapRereadsProperties(t *testing.T) {
574+ hs := newHarness(t, nil)
575+ const win = 0x500001
576+ hs.classic(win)
577+ s := hs.mapAndWait(win)
578+
579+ // A synthetic UnmapNotify (ICCCM withdraw request) is not an unmap.
580+ hs.p.unmapNotify(win, true)
581+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
582+ require.True(t, hs.x.tab.rec(s).ready)
583+
584+ hs.h.EXPECT().Dissociate(s, false).Once()
585+ hs.p.unmapNotify(win, false)
586+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).ready })
587+ r := hs.x.tab.rec(s)
588+ require.False(t, r.mapping)
589+ require.Zero(t, r.serial)
590+ hs.p.waitReq(0, func(q xpeerReq) bool {
591+ return q.major == opSetInputFocus || q.major == opChangeProperty && q.u32(4) == win
592+ })
593+ readUntil(t, hs.x, func() bool {
594+ v, ok := hs.p.prop(win, "WM_STATE")
595+ return ok && len(v.data) == 8 && binary.LittleEndian.Uint32(v.data) == 0
596+ })
597+ _, ok := hs.p.prop(win, "_NET_WM_STATE")
598+ require.False(t, ok, "_NET_WM_STATE deleted")
599+ list, _ := hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
600+ require.Empty(t, list.data)
601+
602+ // Remap starts over.
603+ delete(hs.ready, win)
604+ hs.expectReady(win)
605+ n := hs.count(func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(4) == win })
606+ hs.p.mapRequest(win)
607+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
608+ require.Equal(t, n+propCount, hs.count(func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(4) == win }))
609+ list, _ = hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
610+ require.Equal(t, []uint32{win}, atomsOf(list))
611+}
612+
613+func TestDestroyWhileMappingNeverReady(t *testing.T) {
614+ hs := newHarness(t, nil)
615+ const win = 0x500001
616+ hs.classic(win)
617+ s := hs.create(win)
618+ hs.p.holdReplies()
619+ hs.p.mapRequest(win)
620+ hs.waitMap(win)
621+ hs.h.EXPECT().Dissociate(s, true).Once()
622+ hs.p.destroyNotify(win)
623+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
624+ replies := hs.x.Stats().Replies
625+ hs.p.release() // replies for a window that is gone: xturbo retired their slots
626+ readUntil(t, hs.x, func() bool { return hs.x.conn.Stats().DroppedReplies >= uint64(propCount) })
627+ require.Equal(t, replies, hs.x.Stats().Replies, "none reaches the XWM")
628+}
629+
630+func TestStaleRepliesAfterSlotReuseAreIgnored(t *testing.T) {
631+ hs := newHarness(t, nil)
632+ const old, fresh = 0x500001, 0x500002
633+ hs.classic(old)
634+ hs.classic(fresh)
635+ s := hs.create(old)
636+ hs.p.holdReplies()
637+ hs.p.mapRequest(old)
638+ hs.waitMap(old)
639+ hs.h.EXPECT().Dissociate(s, true).Once()
640+ hs.p.destroyNotify(old)
641+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(old) == 0 })
642+
643+ hs.expectCreated(fresh)
644+ hs.p.createNotify(fresh, false)
645+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(fresh) != 0 })
646+ require.Equal(t, s, hs.x.tab.lookup(fresh), "the freed slot is reused")
647+
648+ hs.p.release()
649+ readUntil(t, hs.x, func() bool { return hs.x.conn.Stats().DroppedReplies >= uint64(propCount) })
650+
651+ // A reply that does get through with the old tag (slot reused, generation
652+ // bumped) is ignored.
653+ oldTag := s | Slot(hs.x.tab.rec(s).gen-1)<<16
654+ rep := make([]byte, 32)
655+ rep[0] = 1
656+ hs.x.onReply(&xturbo.Reply{Kind: kindProp, Args: [4]uint32{uint32(oldTag), propClass}, Raw: rep})
657+ hs.x.onReply(&xturbo.Reply{Kind: kindGeom, Args: [4]uint32{uint32(oldTag)}, Raw: rep})
658+ require.EqualValues(t, 2, hs.x.Stats().Stale)
659+ require.False(t, hs.x.tab.rec(s).ready, "stale replies must not complete the new window")
660+ require.Zero(t, hs.x.tab.rec(s).pending)
661+}
662+
663+// Reparenting.
664+
665+func TestReparentAwayFromRootRemovesManagedWindow(t *testing.T) {
666+ hs := newHarness(t, nil)
667+ const win = 0x500001
668+ hs.classic(win)
669+ s := hs.mapAndWait(win)
670+ hs.h.EXPECT().Dissociate(s, true).Once()
671+ hs.p.reparentNotify(win, 0x600000)
672+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
673+ list, _ := hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
674+ require.Empty(t, list.data)
675+ require.Zero(t, hs.x.tab.n)
676+}
677+
678+func TestReparentAwayOfUnmanagedWindowRemovesIt(t *testing.T) {
679+ hs := newHarness(t, nil)
680+ hs.p.createNotify(0x500009, true)
681+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
682+ hs.p.reparentNotify(0x500009, 0x600000)
683+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) == 0 })
684+}
685+
686+func TestReparentIntoRootAdoptsWindow(t *testing.T) {
687+ hs := newHarness(t, nil)
688+ const win = 0x500001
689+ hs.expectCreated(win)
690+ hs.p.reparentNotify(win, peerRoot)
691+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) != 0 })
692+ require.Equal(t, kindManaged, hs.x.tab.rec(hs.x.tab.lookup(win)).kind)
693+
694+ // Already known: no second Created.
695+ hs.p.reparentNotify(win, peerRoot)
696+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
697+}
698+
699+func TestReparentIntoRootOfOverrideRedirectWindowStaysUnmanaged(t *testing.T) {
700+ hs := newHarness(t, nil)
701+ hs.p.event(21, func(b *[32]byte) {
702+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
703+ binary.LittleEndian.PutUint32(b[8:], 0x500009)
704+ binary.LittleEndian.PutUint32(b[12:], peerRoot)
705+ b[20] = 1
706+ })
707+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
708+ require.Equal(t, kindUnmanaged, hs.x.tab.rec(hs.x.tab.lookup(0x500009)).kind)
709+}
710+
711+func TestReparentOfWMWindowIgnored(t *testing.T) {
712+ hs := newHarness(t, nil)
713+ hs.p.reparentNotify(hs.x.wmWin, peerRoot)
714+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 1 })
715+ require.Zero(t, hs.x.tab.lookup(hs.x.wmWin))
716+}
717+
718+// Window known only before Attach: Attach replays the map flow.
719+func TestAttachMapsWindowsRequestedBeforeAttach(t *testing.T) {
720+ x, p := bootPeer(t, nil)
721+ const win = 0x500001
722+ p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("a\x00B\x00"))
723+ p.createNotify(win, false)
724+ p.mapRequest(win)
725+ readUntil(t, x, func() bool { return x.Stats().Events >= 2 })
726+ h := newMockhost(t)
727+ src := newMockfdSource(t)
728+ src.EXPECT().Remove().Maybe()
729+ post, _ := newPost()
730+ var got WindowInfo
731+ done := false
732+ h.EXPECT().Created(mock.Anything, uint32(win)).Return(ports.WindowID(1)).Once()
733+ h.EXPECT().Ready(mock.Anything, mock.Anything).Run(func(_ Slot, i WindowInfo) { got, done = i, true }).Once()
734+ x.attach(src, h, post)
735+ readUntil(t, x, func() bool { return done })
736+ require.Equal(t, "B", got.AppID)
737+}
738+
739+// Replies complete a map in any order (a server may answer the geometry
740+// before the properties it was asked for first): Ready fires on the last one.
741+func TestMapFlowReadyFiresOnLastReplyInAnyOrder(t *testing.T) {
742+ hs := newHarness(t, nil)
743+ const win = 0x500001
744+ hs.classic(win)
745+ s := hs.create(win)
746+ r := hs.x.tab.rec(s)
747+ r.mapping, r.pending = true, propMaskAll&^propClientIDs
748+ hs.expectReady(win)
749+ order := []int{propCount, propPID, propClass, propNetName, propName, propHints, propNormalHints, propProtocols, propTransientFor, propWindowType, propState}
750+ for i, bit := range order {
751+ require.False(t, hs.x.tab.rec(s).ready, i)
752+ if bit == propCount { // the geometry
753+ hs.x.tab.rec(s).w, hs.x.tab.rec(s).h = 300, 200
754+ hs.x.tab.rec(s).pending &^= propGeom
755+ hs.x.checkReady(s)
756+ continue
757+ }
758+ var val []byte
759+ var format uint8 = 8
760+ switch bit {
761+ case propClass:
762+ val = []byte("a\x00B\x00")
763+ case propNetName:
764+ val = []byte("t")
765+ case propPID:
766+ val, format = le32(5), 32
767+ default:
768+ format = 32
769+ }
770+ x := hs.x
771+ x.applyProp(s, bit, format, val)
772+ x.tab.rec(s).pending &^= 1 << bit
773+ x.checkReady(s)
774+ }
775+ require.True(t, hs.x.tab.rec(s).ready)
776+ require.Equal(t, "B", hs.ready[win].AppID)
777+ require.Equal(t, "t", hs.ready[win].Title)
778+ require.Equal(t, 5, hs.ready[win].PID)
779+}
A internal/adapters/wayland/xwm/props.go +356 -0
@@ -0,0 +1,356 @@
1+package xwm
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "strings"
7+
8+ "bnema.dev/xturbo"
9+ "bnema.dev/xturbo/res"
10+ "bnema.dev/xturbo/xproto"
11+)
12+
13+// Property indices (second Expect argument of kindProp) and their bits in
14+// record.pending. propGeom is the GetGeometry bit; propClientIDs is raised
15+// while a QueryClientIds reply stands in for a missing _NET_WM_PID.
16+const (
17+ propClass = iota
18+ propName
19+ propNetName
20+ propHints
21+ propNormalHints
22+ propProtocols
23+ propTransientFor
24+ propWindowType
25+ propState
26+ propPID
27+ propCount
28+)
29+
30+const (
31+ propGeom uint16 = 1 << propCount
32+ propClientIDs uint16 = propGeom << 1
33+ // propMaskAll is what a map waits for: every property and the geometry.
34+ propMaskAll uint16 = propGeom<<1 - 1
35+)
36+
37+// WM_NORMAL_HINTS flags and WM_HINTS flags.
38+const (
39+ hintInput = 1 << 0
40+ hintMinSize = 1 << 4
41+ hintMaxSize = 1 << 5
42+)
43+
44+// maxPropWords bounds one GetProperty (4 KiB), so titles are truncated there.
45+const maxPropWords = 1024
46+
47+// propIndex maps a property atom to its index, or -1 when it is not watched.
48+func (x *XWM) propIndex(a uint32) int {
49+ switch a {
50+ case uint32(xproto.AtomWmClass):
51+ return propClass
52+ case uint32(xproto.AtomWmName):
53+ return propName
54+ case uint32(xproto.AtomWmHints):
55+ return propHints
56+ case uint32(xproto.AtomWmNormalHints):
57+ return propNormalHints
58+ case uint32(xproto.AtomWmTransientFor):
59+ return propTransientFor
60+ case x.atoms[atomNetWMName]:
61+ return propNetName
62+ case x.atoms[atomWMProtocols]:
63+ return propProtocols
64+ case x.atoms[atomNetWMWindowType]:
65+ return propWindowType
66+ case x.atoms[atomNetWMState]:
67+ return propState
68+ case x.atoms[atomNetWMPID]:
69+ return propPID
70+ }
71+ return -1
72+}
73+
74+func (x *XWM) propAtom(idx int) xproto.Atom {
75+ switch idx {
76+ case propClass:
77+ return xproto.AtomWmClass
78+ case propName:
79+ return xproto.AtomWmName
80+ case propHints:
81+ return xproto.AtomWmHints
82+ case propNormalHints:
83+ return xproto.AtomWmNormalHints
84+ case propTransientFor:
85+ return xproto.AtomWmTransientFor
86+ case propNetName:
87+ return xproto.Atom(x.atoms[atomNetWMName])
88+ case propProtocols:
89+ return xproto.Atom(x.atoms[atomWMProtocols])
90+ case propWindowType:
91+ return xproto.Atom(x.atoms[atomNetWMWindowType])
92+ case propState:
93+ return xproto.Atom(x.atoms[atomNetWMState])
94+ default:
95+ return xproto.Atom(x.atoms[atomNetWMPID])
96+ }
97+}
98+
99+// requestProp queues a GetProperty for property idx of s.
100+func (x *XWM) requestProp(s Slot, idx int) {
101+ rec := x.tab.rec(s)
102+ seq := xproto.GetProperty(x.conn.Buf(), false, xproto.Window(rec.xid), x.propAtom(idx), xproto.AtomAny, 0, maxPropWords)
103+ rec.pending |= 1 << idx
104+ x.expect(seq, kindProp, x.tab.tag(s), uint32(idx))
105+}
106+
107+// onPropertyNotify handles a changed property of a managed window that is
108+// being mapped or is mapped; unwatched atoms cost nothing.
109+func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
110+ e := xproto.PropertyNotifyOf(ev)
111+ s := x.tab.lookup(uint32(e.Window()))
112+ if s == 0 {
113+ return
114+ }
115+ if rec := x.tab.rec(s); rec.kind != kindManaged || !rec.mapping {
116+ return
117+ } else if rec.ready && x.propIndex(uint32(e.Atom())) == propState {
118+ // The XWM owns _NET_WM_STATE of a mapped window: our own writes
119+ // would feed back, and clients change it with ClientMessages.
120+ return
121+ }
122+ idx := x.propIndex(uint32(e.Atom()))
123+ if idx < 0 {
124+ return
125+ }
126+ if e.State() == xproto.PropertyDelete {
127+ x.applyProp(s, idx, 0, nil)
128+ return
129+ }
130+ x.requestProp(s, idx)
131+}
132+
133+// onPropReply decodes one GetProperty reply into the record. An absent
134+// property (format 0) clears its fields.
135+func (x *XWM) onPropReply(s Slot, _ *record, r *xturbo.Reply) {
136+ idx := int(r.Args[1])
137+ if idx < 0 || idx >= propCount {
138+ return
139+ }
140+ var format uint8
141+ var val []byte
142+ if rep, ok := xproto.ParseGetPropertyReply(r.Raw); ok {
143+ format, val = rep.Format(), rep.Value()
144+ }
145+ x.applyProp(s, idx, format, val)
146+ x.tab.rec(s).pending &^= 1 << idx
147+ x.checkReady(s)
148+}
149+
150+func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[4*i:]) }
151+
152+// applyProp stores one decoded property. It does not allocate unless a string
153+// changed. Host calls come last: the record is not touched after them.
154+func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
155+ rec := x.tab.rec(s)
156+ if idx == propClass || idx == propName || idx == propNetName {
157+ if format != 8 {
158+ val = nil
159+ }
160+ } else if format != 32 {
161+ val = nil
162+ }
163+ switch idx {
164+ case propClass:
165+ inst, class := val, []byte(nil)
166+ if i := bytes.IndexByte(val, 0); i >= 0 {
167+ inst, class = val[:i], val[i+1:]
168+ if j := bytes.IndexByte(class, 0); j >= 0 {
169+ class = class[:j]
170+ }
171+ }
172+ app := class
173+ if len(app) == 0 {
174+ app = inst
175+ }
176+ if rec.class == string(app) {
177+ return
178+ }
179+ cls := strings.ToValidUTF8(string(app), "\uFFFD")
180+ if rec.class == cls {
181+ return
182+ }
183+ rec.class = cls
184+ if rec.ready {
185+ x.host.AppID(s, rec.class)
186+ }
187+ case propName:
188+ if !rec.hasNetName {
189+ x.setTitle(s, val)
190+ }
191+ case propNetName:
192+ had := rec.hasNetName
193+ rec.hasNetName = len(val) > 0
194+ if rec.hasNetName {
195+ x.setTitle(s, val)
196+ } else if had && rec.mapping {
197+ x.requestProp(s, propName)
198+ }
199+ case propHints:
200+ rec.input = true
201+ if len(val) >= 8 && word(val, 0)&hintInput != 0 {
202+ rec.input = word(val, 1) != 0
203+ }
204+ case propNormalHints:
205+ rec.minW, rec.minH, rec.maxW, rec.maxH = 0, 0, 0, 0
206+ if len(val) >= 36 {
207+ fl := word(val, 0)
208+ if fl&hintMinSize != 0 {
209+ rec.minW, rec.minH = int32(word(val, 5)), int32(word(val, 6))
210+ }
211+ if fl&hintMaxSize != 0 {
212+ rec.maxW, rec.maxH = int32(word(val, 7)), int32(word(val, 8))
213+ }
214+ }
215+ x.classify(rec)
216+ case propProtocols:
217+ rec.deleteWindow, rec.takeFocus = false, false
218+ for i := 0; i+4 <= len(val); i += 4 {
219+ switch binary.LittleEndian.Uint32(val[i:]) {
220+ case x.atoms[atomWMDeleteWindow]:
221+ rec.deleteWindow = true
222+ case x.atoms[atomWMTakeFocus]:
223+ rec.takeFocus = true
224+ }
225+ }
226+ case propTransientFor:
227+ var t uint32
228+ if len(val) >= 4 {
229+ t = word(val, 0)
230+ }
231+ if t == rec.transient {
232+ return
233+ }
234+ rec.transient = t
235+ x.classify(rec)
236+ if rec.ready {
237+ x.host.Parent(s, x.managedSlot(t))
238+ }
239+ case propWindowType:
240+ rec.wtype = 0
241+ for i := 0; i+4 <= len(val); i += 4 {
242+ if t := x.windowType(binary.LittleEndian.Uint32(val[i:])); t != 0 {
243+ rec.wtype = t
244+ break
245+ }
246+ }
247+ x.classify(rec)
248+ case propState:
249+ var modal, fs bool
250+ for i := 0; i+4 <= len(val); i += 4 {
251+ switch binary.LittleEndian.Uint32(val[i:]) {
252+ case x.atoms[atomNetWMStateModal]:
253+ modal = true
254+ case x.atoms[atomNetWMStateFullscreen]:
255+ fs = true
256+ }
257+ }
258+ changed := fs != rec.fullscreen
259+ rec.modal, rec.fullscreen = modal, fs
260+ if changed && rec.ready {
261+ x.host.Fullscreen(s, fs)
262+ }
263+ case propPID:
264+ if len(val) >= 4 {
265+ rec.pid = int32(word(val, 0))
266+ return
267+ }
268+ rec.pid = 0
269+ if !rec.ready && rec.pending&propClientIDs == 0 && x.conn.Ext().Info(xturbo.ExtRes).Present {
270+ x.queryClientIDs(s)
271+ }
272+ }
273+}
274+
275+// setTitle stores a title when it differs; only a change allocates.
276+func (x *XWM) setTitle(s Slot, val []byte) {
277+ rec := x.tab.rec(s)
278+ if rec.title == string(val) {
279+ return
280+ }
281+ t := strings.ToValidUTF8(string(val), "\uFFFD")
282+ if rec.title == t {
283+ return
284+ }
285+ rec.title = t
286+ if rec.ready {
287+ x.host.Title(s, rec.title)
288+ }
289+}
290+
291+// windowType returns the atom enum of a recognised _NET_WM_WINDOW_TYPE atom,
292+// or 0.
293+func (x *XWM) windowType(a uint32) uint8 {
294+ for t := atomNetWMWindowTypeNormal; t <= atomNetWMWindowTypeDND; t++ {
295+ if x.atoms[t] == a {
296+ return uint8(t)
297+ }
298+ }
299+ return 0
300+}
301+
302+// floatingType reports whether a window type floats by itself.
303+func floatingType(t uint8) bool {
304+ switch int(t) {
305+ case atomNetWMWindowTypeDialog, atomNetWMWindowTypeUtility, atomNetWMWindowTypeSplash, atomNetWMWindowTypeToolbar:
306+ return true
307+ }
308+ return false
309+}
310+
311+// classify sets rec.floating, the single source of truth: a dialog-like
312+// window type, a transient for a managed window, or a fixed size.
313+func (x *XWM) classify(rec *record) {
314+ fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
315+ rec.floating = floatingType(rec.wtype) || x.managedSlot(rec.transient) != 0 || fixed
316+}
317+
318+// managedSlot returns the slot of the managed window xid, or 0.
319+func (x *XWM) managedSlot(xid uint32) Slot {
320+ if xid == 0 {
321+ return 0
322+ }
323+ if s := x.tab.lookup(xid); s != 0 && x.tab.rec(s).kind == kindManaged {
324+ return s
325+ }
326+ return 0
327+}
328+
329+// queryClientIDs asks XRes for the PID of the client owning s, which stands
330+// in for a missing _NET_WM_PID: Ready waits for it (propClientIDs).
331+func (x *XWM) queryClientIDs(s Slot) {
332+ rec := x.tab.rec(s)
333+ specs := [1]res.ClientIdSpec{{Client: rec.xid, Mask: res.ClientIdMaskLocalClientPID}}
334+ seq := res.QueryClientIds(x.conn.Buf(), specs[:])
335+ if seq == 0 {
336+ return
337+ }
338+ rec.pending |= propClientIDs
339+ x.expect(seq, kindClientIds, x.tab.tag(s), 0)
340+}
341+
342+// onClientIdsReply stores the PID XRes found and releases the map.
343+func (x *XWM) onClientIdsReply(s Slot, rec *record, r *xturbo.Reply) {
344+ if q, ok := res.ParseQueryClientIdsReply(r.Raw); ok {
345+ it := q.Ids()
346+ for it.Next() {
347+ v := it.Value()
348+ if v.Spec().Mask == res.ClientIdMaskLocalClientPID && v.Value().Len() > 0 {
349+ rec.pid = int32(v.Value().At(0))
350+ break
351+ }
352+ }
353+ }
354+ rec.pending &^= propClientIDs
355+ x.checkReady(s)
356+}
A internal/adapters/wayland/xwm/streams_test.go +183 -0
@@ -0,0 +1,183 @@
1+package xwm
2+
3+import (
4+ "encoding/hex"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "bnema.dev/xturbo/xproto"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// The recorded streams are the bytes an X server wrote to the XWM during a
17+// steady-state session of one mapped window, captured from the scripted xpeer
18+// (real encoded X packets: events, GetProperty replies). The scenario is
19+// deterministic, so a replay meets the request sequence numbers it recorded.
20+// They are not xtrace captures of a real Xwayland: xpeer speaks the same wire
21+// protocol and keeps the fixture reproducible. Regenerate with
22+//
23+// XWM_UPDATE_STREAMS=1 go test ./internal/adapters/wayland/xwm -run TestRecordedStreams
24+const (
25+ streamCycles = 400
26+ streamWarm = 20 // cycles replayed before measuring
27+)
28+
29+const streamWin = 0x500001
30+
31+type streamKind struct {
32+ name string
33+ // perCycle is Events+Replies the XWM sees per cycle.
34+ perCycle int
35+ cycle func(hs *harness)
36+}
37+
38+var streamKinds = []streamKind{
39+ // A title notification the XWM answers with one GetProperty whose reply
40+ // equals the stored title.
41+ {"property_unchanged", 2, func(hs *harness) {
42+ before := hs.x.Stats().Replies
43+ hs.p.propertyNotify(streamWin, hs.p.atom("_NET_WM_NAME"), false)
44+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
45+ }},
46+ // The same plus ConfigureNotify, FocusIn and MapNotify.
47+ {"steady", 5, func(hs *harness) {
48+ before := hs.x.Stats().Replies
49+ hs.p.propertyNotify(streamWin, hs.p.atom("_NET_WM_NAME"), false)
50+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
51+ events := hs.x.Stats().Events
52+ hs.p.configureNotify(streamWin)
53+ hs.p.focusIn(streamWin)
54+ hs.p.mapNotify(streamWin)
55+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Events >= events+3 })
56+ }},
57+}
58+
59+// streamScenario boots, maps one window and parks the peer behind a poke so
60+// nothing but the test writes to the XWM afterwards (an allocation count is
61+// process-wide). The poke is part of the scenario, recorded or replayed.
62+func streamScenario(t *testing.T) *harness {
63+ t.Helper()
64+ hs := newHarness(t, nil)
65+ hs.classic(streamWin)
66+ hs.mapAndWait(streamWin)
67+ // The steady state is a focused window: a FocusIn on it is not denied,
68+ // so no request depends on how events are batched into flushes. The
69+ // poke makes sure the focus requests are out before the peer pauses.
70+ hs.x.Focus(hs.x.tab.lookup(streamWin))
71+ poke(hs)
72+ return hs
73+}
74+
75+func poke(hs *harness) {
76+ hs.t.Helper()
77+ n := len(hs.p.requests())
78+ xproto.GetInputFocus(hs.x.conn.Buf())
79+ hs.x.flush()
80+ hs.p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus })
81+}
82+
83+func recordStream(t *testing.T, k streamKind) []byte {
84+ hs := streamScenario(t)
85+ poke(hs)
86+ hs.p.mu.Lock()
87+ hs.p.record = true
88+ hs.p.mu.Unlock()
89+ for i := 0; i < streamCycles; i++ {
90+ k.cycle(hs)
91+ }
92+ hs.p.mu.Lock()
93+ defer hs.p.mu.Unlock()
94+ hs.p.record = false
95+ return append([]byte(nil), hs.p.tap...)
96+}
97+
98+func streamPath(k streamKind) string {
99+ return filepath.Join("testdata", "streams", k.name+".hex")
100+}
101+
102+func loadStream(t *testing.T, k streamKind) []byte {
103+ t.Helper()
104+ text, err := os.ReadFile(streamPath(k))
105+ require.NoError(t, err)
106+ b, err := hex.DecodeString(strings.Join(strings.Fields(string(text)), ""))
107+ require.NoError(t, err)
108+ return b
109+}
110+
111+func encodeHex(b []byte) string {
112+ var sb strings.Builder
113+ for len(b) > 0 {
114+ n := min(32, len(b))
115+ sb.WriteString(hex.EncodeToString(b[:n]))
116+ sb.WriteByte('\n')
117+ b = b[n:]
118+ }
119+ return sb.String()
120+}
121+
122+func TestRecordedStreams(t *testing.T) {
123+ for _, k := range streamKinds {
124+ t.Run(k.name, func(t *testing.T) {
125+ got := recordStream(t, k)
126+ if os.Getenv("XWM_UPDATE_STREAMS") != "" {
127+ require.NoError(t, os.MkdirAll(filepath.Dir(streamPath(k)), 0o755))
128+ require.NoError(t, os.WriteFile(streamPath(k), []byte(encodeHex(got)), 0o644))
129+ }
130+ require.Equal(t, loadStream(t, k), got, "the committed stream no longer matches the protocol the XWM speaks")
131+ })
132+ }
133+}
134+
135+// replay feeds the recorded stream and reports the mallocs of the part after
136+// the warm-up cycles.
137+func replay(t *testing.T, k streamKind) (mallocs uint64, hs *harness) {
138+ t.Helper()
139+ stream := loadStream(t, k)
140+ hs = streamScenario(t)
141+ hs.p.pause()
142+ poke(hs)
143+ _, err := hs.p.f.Write(stream)
144+ require.NoError(t, err)
145+
146+ x := hs.x
147+ base := x.Stats().Events + x.Stats().Replies
148+ drive := func(until uint64) {
149+ deadline := time.Now().Add(10 * time.Second)
150+ for x.Stats().Events+x.Stats().Replies < until {
151+ if time.Now().After(deadline) { // no formatting arguments: boxing them would count as mallocs
152+ t.Fatal("stream stalled")
153+ }
154+ before := x.Stats().Events + x.Stats().Replies
155+ x.onReadable()
156+ if x.Stats().Events+x.Stats().Replies == before {
157+ runtime.Gosched()
158+ }
159+ }
160+ }
161+ drive(base + uint64(streamWarm*k.perCycle))
162+ target := base + uint64(streamCycles*k.perCycle)
163+
164+ var m0, m1 runtime.MemStats
165+ runtime.GC()
166+ runtime.ReadMemStats(&m0)
167+ drive(target)
168+ runtime.ReadMemStats(&m1)
169+ require.Equal(t, target, x.Stats().Events+x.Stats().Replies)
170+ return m1.Mallocs - m0.Mallocs, hs
171+}
172+
173+func TestPropertyUnchangedAllocations(t *testing.T) {
174+ m, hs := replay(t, streamKinds[0])
175+ require.Zero(t, m, "unchanged property cycles must not allocate")
176+ require.Equal(t, "net title", hs.x.tab.rec(hs.x.tab.lookup(streamWin)).title)
177+}
178+
179+func TestDispatchAllocations(t *testing.T) {
180+ m, hs := replay(t, streamKinds[1])
181+ require.Zero(t, m, "steady-state event dispatch must not allocate")
182+ require.True(t, hs.x.tab.rec(hs.x.tab.lookup(streamWin)).mapped)
183+}
A internal/adapters/wayland/xwm/table.go +228 -0
@@ -0,0 +1,228 @@
1+package xwm
2+
3+import (
4+ "math/bits"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Record kinds.
10+const (
11+ kindFree uint8 = iota
12+ kindManaged
13+ kindUnmanaged
14+)
15+
16+const (
17+ initialSlots = 256
18+ // maxSlots bounds the table: a Slot travels in the low 16 bits of an
19+ // Expect argument.
20+ maxSlots = 1<<16 - 1
21+ indexGone = 0xFFFFFFFF // tombstone in table.index
22+)
23+
24+// pending is the configure state not yet (or last) sent to the X server.
25+type pending struct {
26+ x, y int32
27+ w, h uint32
28+ fullscreen, activated, dirty bool
29+}
30+
31+// record is one X window known to the XWM. Strings are the only per-record
32+// heap data.
33+type record struct {
34+ xid uint32
35+ id ports.WindowID
36+ kind uint8
37+ gen uint16 // bumped on insert; stale Expect replies are told apart by it
38+
39+ // X state
40+ override, mapped, mapping, ready bool
41+ x, y int32
42+ w, h uint32
43+
44+ // properties
45+ pending uint16 // propMask bits still awaited
46+ input, deleteWindow, takeFocus bool
47+ wtype uint8
48+ modal, fullscreen bool
49+ transient uint32 // xid
50+ pid int32
51+ minW, minH, maxW, maxH int32
52+ title, class string // owned, replaced only on change
53+ hasNetName bool
54+ floating bool // dialog-like type; set with the properties
55+ sized bool // the client asked for its size before Ready
56+
57+ // pairing
58+ serial uint64
59+ surface uint64
60+
61+ // configure
62+ pend, sent pending
63+ stateSent uint8 // _NET_WM_STATE bits last written
64+}
65+
66+// table is a slab of records with an open-addressing index by X id. No Go map.
67+type table struct {
68+ recs []record // slab, len == cap, power of two
69+ free []uint32 // free slot indices (LIFO)
70+ index []uint32 // slot+1, 0 empty, indexGone tombstone; len == 2*len(recs)
71+ shift uint8
72+ n int
73+ tombs int
74+}
75+
76+func newTable(size int) *table {
77+ t := &table{}
78+ t.resize(size)
79+ return t
80+}
81+
82+// resize reallocates the slab, free list and index for size records and
83+// reindexes the live ones.
84+func (t *table) resize(size int) {
85+ old := t.recs
86+ t.recs = make([]record, size)
87+ copy(t.recs, old)
88+ t.free = make([]uint32, 0, size)
89+ for i := min(size, maxSlots) - 1; i >= 0; i-- {
90+ if i >= len(old) || old[i].kind == kindFree {
91+ t.free = append(t.free, uint32(i))
92+ }
93+ }
94+ t.index = make([]uint32, 2*size)
95+ t.shift = uint8(32 - bits.TrailingZeros(uint(len(t.index))))
96+ t.reindex()
97+}
98+
99+// reindex rebuilds the index from the live records, dropping tombstones.
100+// It does not allocate.
101+func (t *table) reindex() {
102+ clear(t.index)
103+ t.tombs = 0
104+ for i := range t.recs {
105+ if t.recs[i].kind != kindFree {
106+ t.indexAdd(t.recs[i].xid, uint32(i)+1)
107+ }
108+ }
109+}
110+
111+func (t *table) hash(xid uint32) uint32 { return (xid * 0x9E3779B1) >> t.shift }
112+
113+// indexAdd places slot for xid, which must be absent, in the first empty or
114+// tombstone position of its probe sequence.
115+func (t *table) indexAdd(xid, slot uint32) {
116+ mask := uint32(len(t.index) - 1)
117+ for i := t.hash(xid); ; i = (i + 1) & mask {
118+ switch t.index[i] {
119+ case 0:
120+ t.index[i] = slot
121+ return
122+ case indexGone:
123+ t.index[i] = slot
124+ t.tombs--
125+ return
126+ }
127+ }
128+}
129+
130+// grow doubles the table. It reports false at maxSlots.
131+func (t *table) grow() bool {
132+ if len(t.recs) >= 1<<16 {
133+ return false
134+ }
135+ t.resize(2 * len(t.recs))
136+ return len(t.free) > 0
137+}
138+
139+// insert returns the slot of xid, creating a managed record (generation
140+// bumped) when absent. It returns 0 when the table is full. Growth is the
141+// only allocation.
142+func (t *table) insert(xid uint32) Slot {
143+ if s := t.lookup(xid); s != 0 {
144+ return s
145+ }
146+ if len(t.free) == 0 && !t.grow() {
147+ return 0
148+ }
149+ i := t.free[len(t.free)-1]
150+ t.free = t.free[:len(t.free)-1]
151+ if t.n+t.tombs+1 > len(t.index)/4*3 { // keep an empty index cell for lookup to stop at
152+ t.reindex()
153+ }
154+ r := &t.recs[i]
155+ gen := r.gen + 1
156+ if gen == 0 {
157+ gen = 1
158+ }
159+ *r = record{xid: xid, kind: kindManaged, gen: gen, input: true}
160+ t.indexAdd(xid, i+1)
161+ t.n++
162+ return Slot(i + 1)
163+}
164+
165+// remove frees s. The record is zeroed except its generation.
166+func (t *table) remove(s Slot) {
167+ r := t.rec(s)
168+ if r == nil || r.kind == kindFree {
169+ return
170+ }
171+ mask := uint32(len(t.index) - 1)
172+ for i := t.hash(r.xid); ; i = (i + 1) & mask {
173+ v := t.index[i]
174+ if v == 0 {
175+ break
176+ }
177+ if v == uint32(s) {
178+ t.index[i] = indexGone
179+ t.tombs++
180+ break
181+ }
182+ }
183+ *r = record{gen: r.gen}
184+ t.free = append(t.free, uint32(s)-1)
185+ t.n--
186+ if t.n+t.tombs > len(t.index)/4*3 {
187+ t.reindex()
188+ }
189+}
190+
191+// lookup returns the slot of xid, or 0.
192+func (t *table) lookup(xid uint32) Slot {
193+ mask := uint32(len(t.index) - 1)
194+ for i := t.hash(xid); ; i = (i + 1) & mask {
195+ v := t.index[i]
196+ if v == 0 {
197+ return 0
198+ }
199+ if v != indexGone && t.recs[v-1].xid == xid {
200+ return Slot(v)
201+ }
202+ }
203+}
204+
205+// rec returns the record of s, or nil when s is out of range. The pointer is
206+// valid only until the next insert, host call or anything else that can grow
207+// the table or free s: never hold it across those, fetch it again with rec(s).
208+func (t *table) rec(s Slot) *record {
209+ if s == 0 || int(s) > len(t.recs) {
210+ return nil
211+ }
212+ return &t.recs[s-1]
213+}
214+
215+// tag is the Expect argument naming s: slot in the low 16 bits, generation
216+// in the high 16.
217+func (t *table) tag(s Slot) uint32 { return uint32(s) | uint32(t.recs[s-1].gen)<<16 }
218+
219+// live resolves an Expect tag. It returns nil when the slot was freed or
220+// reused since the request was sent.
221+func (t *table) live(tag uint32) (Slot, *record) {
222+ s := Slot(tag & 0xFFFF)
223+ r := t.rec(s)
224+ if r == nil || r.kind == kindFree || r.gen != uint16(tag>>16) {
225+ return 0, nil
226+ }
227+ return s, r
228+}
A internal/adapters/wayland/xwm/table_test.go +138 -0
@@ -0,0 +1,138 @@
1+package xwm
2+
3+import (
4+ "math/rand/v2"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTableAgainstMap(t *testing.T) {
11+ rng := rand.New(rand.NewPCG(1, 2))
12+ tab := newTable(initialSlots)
13+ model := map[uint32]Slot{}
14+ var xids []uint32
15+ for i := 0; i < 10000; i++ {
16+ switch op := rng.IntN(3); {
17+ case op < 2 || len(xids) == 0:
18+ xid := rng.Uint32()&0x1fffff | 0x200000
19+ if _, ok := model[xid]; ok {
20+ continue
21+ }
22+ s := tab.insert(xid)
23+ require.NotZero(t, s)
24+ model[xid] = s
25+ xids = append(xids, xid)
26+ default:
27+ k := rng.IntN(len(xids))
28+ xid := xids[k]
29+ xids[k] = xids[len(xids)-1]
30+ xids = xids[:len(xids)-1]
31+ tab.remove(model[xid])
32+ delete(model, xid)
33+ }
34+ if i%500 == 0 {
35+ for xid, s := range model {
36+ require.Equal(t, s, tab.lookup(xid))
37+ }
38+ require.Zero(t, tab.lookup(rng.Uint32()|1<<31))
39+ }
40+ }
41+ require.Equal(t, len(model), tab.n)
42+ for xid, s := range model {
43+ require.Equal(t, s, tab.lookup(xid))
44+ require.Equal(t, xid, tab.rec(s).xid)
45+ }
46+}
47+
48+func TestTableGrowth(t *testing.T) {
49+ tab := newTable(initialSlots)
50+ slots := make([]Slot, 0, initialSlots+1)
51+ for i := 0; i < initialSlots; i++ {
52+ slots = append(slots, tab.insert(uint32(0x200000+i)))
53+ }
54+ require.Len(t, tab.recs, initialSlots)
55+ tab.rec(slots[7]).title = "kept"
56+ slots = append(slots, tab.insert(0x300000))
57+ require.Len(t, tab.recs, 2*initialSlots)
58+ require.Len(t, tab.index, 4*initialSlots)
59+ for i, s := range slots[:initialSlots] {
60+ require.Equal(t, s, tab.lookup(uint32(0x200000+i)))
61+ }
62+ require.Equal(t, "kept", tab.rec(slots[7]).title, "growth keeps records")
63+ require.Equal(t, slots[initialSlots], tab.lookup(0x300000))
64+}
65+
66+func TestTableTombstoneReuse(t *testing.T) {
67+ tab := newTable(initialSlots)
68+ for i := 0; i < 5000; i++ { // far more churn than slots: tombstones must be recycled
69+ s := tab.insert(uint32(0x200000 + i))
70+ require.NotZero(t, s)
71+ require.Equal(t, s, tab.lookup(uint32(0x200000+i)))
72+ tab.remove(s)
73+ require.Zero(t, tab.lookup(uint32(0x200000+i)))
74+ }
75+ require.Len(t, tab.recs, initialSlots)
76+ require.Zero(t, tab.n)
77+}
78+
79+func TestTableGenerationAndTag(t *testing.T) {
80+ tab := newTable(initialSlots)
81+ s := tab.insert(0x200001)
82+ tag := tab.tag(s)
83+ got, rec := tab.live(tag)
84+ require.Equal(t, s, got)
85+ require.NotNil(t, rec)
86+ tab.remove(s)
87+ _, rec = tab.live(tag)
88+ require.Nil(t, rec, "freed slot")
89+ s2 := tab.insert(0x200002)
90+ require.Equal(t, s, s2, "LIFO free list reuses the slot")
91+ _, rec = tab.live(tag)
92+ require.Nil(t, rec, "stale tag after slot reuse")
93+ _, rec = tab.live(tab.tag(s2))
94+ require.NotNil(t, rec)
95+}
96+
97+func TestTableFull(t *testing.T) {
98+ tab := newTable(initialSlots)
99+ for i := 0; i < maxSlots; i++ {
100+ require.NotZero(t, tab.insert(uint32(i+1)), i)
101+ }
102+ require.Zero(t, tab.insert(0xFFFFFF), "table is bounded by the 16-bit slot in Expect tags")
103+ require.Equal(t, Slot(maxSlots), tab.lookup(maxSlots))
104+}
105+
106+func TestTableInsertExisting(t *testing.T) {
107+ tab := newTable(initialSlots)
108+ s := tab.insert(5)
109+ require.Equal(t, s, tab.insert(5))
110+ require.Equal(t, 1, tab.n)
111+}
112+
113+func TestTableLookupAllocations(t *testing.T) {
114+ tab := newTable(initialSlots)
115+ for i := 0; i < 200; i++ {
116+ tab.insert(uint32(0x200000 + i))
117+ }
118+ var sink Slot
119+ n := testing.AllocsPerRun(1000, func() {
120+ sink += tab.lookup(0x200010)
121+ sink += tab.lookup(0x7fffffff) // absent
122+ _, r := tab.live(tab.tag(3))
123+ if r != nil {
124+ sink++
125+ }
126+ })
127+ require.Zero(t, n)
128+ require.NotZero(t, sink)
129+}
130+
131+func TestTableInsertRemoveAllocations(t *testing.T) {
132+ tab := newTable(initialSlots)
133+ n := testing.AllocsPerRun(1000, func() {
134+ s := tab.insert(0x200001)
135+ tab.remove(s)
136+ })
137+ require.Zero(t, n)
138+}
A internal/adapters/wayland/xwm/testdata/streams/property_unchanged.hex +950 -0
@@ -0,0 +1,950 @@
1+1c00650001005000f30300000000000000000000000000000000000000000000
2+0108660003000000ef0300000000000009000000000000000000000000000000
3+6e6574207469746c650000001c00660001005000f30300000000000000000000
4+0000000000000000000000000108670003000000ef0300000000000009000000
5+0000000000000000000000006e6574207469746c650000001c00670001005000
6+f303000000000000000000000000000000000000000000000108680003000000
7+ef03000000000000090000000000000000000000000000006e6574207469746c
8+650000001c00680001005000f303000000000000000000000000000000000000
9+000000000108690003000000ef03000000000000090000000000000000000000
10+000000006e6574207469746c650000001c00690001005000f303000000000000
11+0000000000000000000000000000000001086a0003000000ef03000000000000
12+090000000000000000000000000000006e6574207469746c650000001c006a00
13+01005000f3030000000000000000000000000000000000000000000001086b00
14+03000000ef03000000000000090000000000000000000000000000006e657420
15+7469746c650000001c006b0001005000f3030000000000000000000000000000
16+000000000000000001086c0003000000ef030000000000000900000000000000
17+00000000000000006e6574207469746c650000001c006c0001005000f3030000
18+000000000000000000000000000000000000000001086d0003000000ef030000
19+00000000090000000000000000000000000000006e6574207469746c65000000
20+1c006d0001005000f30300000000000000000000000000000000000000000000
21+01086e0003000000ef0300000000000009000000000000000000000000000000
22+6e6574207469746c650000001c006e0001005000f30300000000000000000000
23+00000000000000000000000001086f0003000000ef0300000000000009000000
24+0000000000000000000000006e6574207469746c650000001c006f0001005000
25+f303000000000000000000000000000000000000000000000108700003000000
26+ef03000000000000090000000000000000000000000000006e6574207469746c
27+650000001c00700001005000f303000000000000000000000000000000000000
28+000000000108710003000000ef03000000000000090000000000000000000000
29+000000006e6574207469746c650000001c00710001005000f303000000000000
30+000000000000000000000000000000000108720003000000ef03000000000000
31+090000000000000000000000000000006e6574207469746c650000001c007200
32+01005000f3030000000000000000000000000000000000000000000001087300
33+03000000ef03000000000000090000000000000000000000000000006e657420
34+7469746c650000001c00730001005000f3030000000000000000000000000000
35+00000000000000000108740003000000ef030000000000000900000000000000
36+00000000000000006e6574207469746c650000001c00740001005000f3030000
37+00000000000000000000000000000000000000000108750003000000ef030000
38+00000000090000000000000000000000000000006e6574207469746c65000000
39+1c00750001005000f30300000000000000000000000000000000000000000000
40+0108760003000000ef0300000000000009000000000000000000000000000000
41+6e6574207469746c650000001c00760001005000f30300000000000000000000
42+0000000000000000000000000108770003000000ef0300000000000009000000
43+0000000000000000000000006e6574207469746c650000001c00770001005000
44+f303000000000000000000000000000000000000000000000108780003000000
45+ef03000000000000090000000000000000000000000000006e6574207469746c
46+650000001c00780001005000f303000000000000000000000000000000000000
47+000000000108790003000000ef03000000000000090000000000000000000000
48+000000006e6574207469746c650000001c00790001005000f303000000000000
49+0000000000000000000000000000000001087a0003000000ef03000000000000
50+090000000000000000000000000000006e6574207469746c650000001c007a00
51+01005000f3030000000000000000000000000000000000000000000001087b00
52+03000000ef03000000000000090000000000000000000000000000006e657420
53+7469746c650000001c007b0001005000f3030000000000000000000000000000
54+000000000000000001087c0003000000ef030000000000000900000000000000
55+00000000000000006e6574207469746c650000001c007c0001005000f3030000
56+000000000000000000000000000000000000000001087d0003000000ef030000
57+00000000090000000000000000000000000000006e6574207469746c65000000
58+1c007d0001005000f30300000000000000000000000000000000000000000000
59+01087e0003000000ef0300000000000009000000000000000000000000000000
60+6e6574207469746c650000001c007e0001005000f30300000000000000000000
61+00000000000000000000000001087f0003000000ef0300000000000009000000
62+0000000000000000000000006e6574207469746c650000001c007f0001005000
63+f303000000000000000000000000000000000000000000000108800003000000
64+ef03000000000000090000000000000000000000000000006e6574207469746c
65+650000001c00800001005000f303000000000000000000000000000000000000
66+000000000108810003000000ef03000000000000090000000000000000000000
67+000000006e6574207469746c650000001c00810001005000f303000000000000
68+000000000000000000000000000000000108820003000000ef03000000000000
69+090000000000000000000000000000006e6574207469746c650000001c008200
70+01005000f3030000000000000000000000000000000000000000000001088300
71+03000000ef03000000000000090000000000000000000000000000006e657420
72+7469746c650000001c00830001005000f3030000000000000000000000000000
73+00000000000000000108840003000000ef030000000000000900000000000000
74+00000000000000006e6574207469746c650000001c00840001005000f3030000
75+00000000000000000000000000000000000000000108850003000000ef030000
76+00000000090000000000000000000000000000006e6574207469746c65000000
77+1c00850001005000f30300000000000000000000000000000000000000000000
78+0108860003000000ef0300000000000009000000000000000000000000000000
79+6e6574207469746c650000001c00860001005000f30300000000000000000000
80+0000000000000000000000000108870003000000ef0300000000000009000000
81+0000000000000000000000006e6574207469746c650000001c00870001005000
82+f303000000000000000000000000000000000000000000000108880003000000
83+ef03000000000000090000000000000000000000000000006e6574207469746c
84+650000001c00880001005000f303000000000000000000000000000000000000
85+000000000108890003000000ef03000000000000090000000000000000000000
86+000000006e6574207469746c650000001c00890001005000f303000000000000
87+0000000000000000000000000000000001088a0003000000ef03000000000000
88+090000000000000000000000000000006e6574207469746c650000001c008a00
89+01005000f3030000000000000000000000000000000000000000000001088b00
90+03000000ef03000000000000090000000000000000000000000000006e657420
91+7469746c650000001c008b0001005000f3030000000000000000000000000000
92+000000000000000001088c0003000000ef030000000000000900000000000000
93+00000000000000006e6574207469746c650000001c008c0001005000f3030000
94+000000000000000000000000000000000000000001088d0003000000ef030000
95+00000000090000000000000000000000000000006e6574207469746c65000000
96+1c008d0001005000f30300000000000000000000000000000000000000000000
97+01088e0003000000ef0300000000000009000000000000000000000000000000
98+6e6574207469746c650000001c008e0001005000f30300000000000000000000
99+00000000000000000000000001088f0003000000ef0300000000000009000000
100+0000000000000000000000006e6574207469746c650000001c008f0001005000
101+f303000000000000000000000000000000000000000000000108900003000000
102+ef03000000000000090000000000000000000000000000006e6574207469746c
103+650000001c00900001005000f303000000000000000000000000000000000000
104+000000000108910003000000ef03000000000000090000000000000000000000
105+000000006e6574207469746c650000001c00910001005000f303000000000000
106+000000000000000000000000000000000108920003000000ef03000000000000
107+090000000000000000000000000000006e6574207469746c650000001c009200
108+01005000f3030000000000000000000000000000000000000000000001089300
109+03000000ef03000000000000090000000000000000000000000000006e657420
110+7469746c650000001c00930001005000f3030000000000000000000000000000
111+00000000000000000108940003000000ef030000000000000900000000000000
112+00000000000000006e6574207469746c650000001c00940001005000f3030000
113+00000000000000000000000000000000000000000108950003000000ef030000
114+00000000090000000000000000000000000000006e6574207469746c65000000
115+1c00950001005000f30300000000000000000000000000000000000000000000
116+0108960003000000ef0300000000000009000000000000000000000000000000
117+6e6574207469746c650000001c00960001005000f30300000000000000000000
118+0000000000000000000000000108970003000000ef0300000000000009000000
119+0000000000000000000000006e6574207469746c650000001c00970001005000
120+f303000000000000000000000000000000000000000000000108980003000000
121+ef03000000000000090000000000000000000000000000006e6574207469746c
122+650000001c00980001005000f303000000000000000000000000000000000000
123+000000000108990003000000ef03000000000000090000000000000000000000
124+000000006e6574207469746c650000001c00990001005000f303000000000000
125+0000000000000000000000000000000001089a0003000000ef03000000000000
126+090000000000000000000000000000006e6574207469746c650000001c009a00
127+01005000f3030000000000000000000000000000000000000000000001089b00
128+03000000ef03000000000000090000000000000000000000000000006e657420
129+7469746c650000001c009b0001005000f3030000000000000000000000000000
130+000000000000000001089c0003000000ef030000000000000900000000000000
131+00000000000000006e6574207469746c650000001c009c0001005000f3030000
132+000000000000000000000000000000000000000001089d0003000000ef030000
133+00000000090000000000000000000000000000006e6574207469746c65000000
134+1c009d0001005000f30300000000000000000000000000000000000000000000
135+01089e0003000000ef0300000000000009000000000000000000000000000000
136+6e6574207469746c650000001c009e0001005000f30300000000000000000000
137+00000000000000000000000001089f0003000000ef0300000000000009000000
138+0000000000000000000000006e6574207469746c650000001c009f0001005000
139+f303000000000000000000000000000000000000000000000108a00003000000
140+ef03000000000000090000000000000000000000000000006e6574207469746c
141+650000001c00a00001005000f303000000000000000000000000000000000000
142+000000000108a10003000000ef03000000000000090000000000000000000000
143+000000006e6574207469746c650000001c00a10001005000f303000000000000
144+000000000000000000000000000000000108a20003000000ef03000000000000
145+090000000000000000000000000000006e6574207469746c650000001c00a200
146+01005000f303000000000000000000000000000000000000000000000108a300
147+03000000ef03000000000000090000000000000000000000000000006e657420
148+7469746c650000001c00a30001005000f3030000000000000000000000000000
149+00000000000000000108a40003000000ef030000000000000900000000000000
150+00000000000000006e6574207469746c650000001c00a40001005000f3030000
151+00000000000000000000000000000000000000000108a50003000000ef030000
152+00000000090000000000000000000000000000006e6574207469746c65000000
153+1c00a50001005000f30300000000000000000000000000000000000000000000
154+0108a60003000000ef0300000000000009000000000000000000000000000000
155+6e6574207469746c650000001c00a60001005000f30300000000000000000000
156+0000000000000000000000000108a70003000000ef0300000000000009000000
157+0000000000000000000000006e6574207469746c650000001c00a70001005000
158+f303000000000000000000000000000000000000000000000108a80003000000
159+ef03000000000000090000000000000000000000000000006e6574207469746c
160+650000001c00a80001005000f303000000000000000000000000000000000000
161+000000000108a90003000000ef03000000000000090000000000000000000000
162+000000006e6574207469746c650000001c00a90001005000f303000000000000
163+000000000000000000000000000000000108aa0003000000ef03000000000000
164+090000000000000000000000000000006e6574207469746c650000001c00aa00
165+01005000f303000000000000000000000000000000000000000000000108ab00
166+03000000ef03000000000000090000000000000000000000000000006e657420
167+7469746c650000001c00ab0001005000f3030000000000000000000000000000
168+00000000000000000108ac0003000000ef030000000000000900000000000000
169+00000000000000006e6574207469746c650000001c00ac0001005000f3030000
170+00000000000000000000000000000000000000000108ad0003000000ef030000
171+00000000090000000000000000000000000000006e6574207469746c65000000
172+1c00ad0001005000f30300000000000000000000000000000000000000000000
173+0108ae0003000000ef0300000000000009000000000000000000000000000000
174+6e6574207469746c650000001c00ae0001005000f30300000000000000000000
175+0000000000000000000000000108af0003000000ef0300000000000009000000
176+0000000000000000000000006e6574207469746c650000001c00af0001005000
177+f303000000000000000000000000000000000000000000000108b00003000000
178+ef03000000000000090000000000000000000000000000006e6574207469746c
179+650000001c00b00001005000f303000000000000000000000000000000000000
180+000000000108b10003000000ef03000000000000090000000000000000000000
181+000000006e6574207469746c650000001c00b10001005000f303000000000000
182+000000000000000000000000000000000108b20003000000ef03000000000000
183+090000000000000000000000000000006e6574207469746c650000001c00b200
184+01005000f303000000000000000000000000000000000000000000000108b300
185+03000000ef03000000000000090000000000000000000000000000006e657420
186+7469746c650000001c00b30001005000f3030000000000000000000000000000
187+00000000000000000108b40003000000ef030000000000000900000000000000
188+00000000000000006e6574207469746c650000001c00b40001005000f3030000
189+00000000000000000000000000000000000000000108b50003000000ef030000
190+00000000090000000000000000000000000000006e6574207469746c65000000
191+1c00b50001005000f30300000000000000000000000000000000000000000000
192+0108b60003000000ef0300000000000009000000000000000000000000000000
193+6e6574207469746c650000001c00b60001005000f30300000000000000000000
194+0000000000000000000000000108b70003000000ef0300000000000009000000
195+0000000000000000000000006e6574207469746c650000001c00b70001005000
196+f303000000000000000000000000000000000000000000000108b80003000000
197+ef03000000000000090000000000000000000000000000006e6574207469746c
198+650000001c00b80001005000f303000000000000000000000000000000000000
199+000000000108b90003000000ef03000000000000090000000000000000000000
200+000000006e6574207469746c650000001c00b90001005000f303000000000000
201+000000000000000000000000000000000108ba0003000000ef03000000000000
202+090000000000000000000000000000006e6574207469746c650000001c00ba00
203+01005000f303000000000000000000000000000000000000000000000108bb00
204+03000000ef03000000000000090000000000000000000000000000006e657420
205+7469746c650000001c00bb0001005000f3030000000000000000000000000000
206+00000000000000000108bc0003000000ef030000000000000900000000000000
207+00000000000000006e6574207469746c650000001c00bc0001005000f3030000
208+00000000000000000000000000000000000000000108bd0003000000ef030000
209+00000000090000000000000000000000000000006e6574207469746c65000000
210+1c00bd0001005000f30300000000000000000000000000000000000000000000
211+0108be0003000000ef0300000000000009000000000000000000000000000000
212+6e6574207469746c650000001c00be0001005000f30300000000000000000000
213+0000000000000000000000000108bf0003000000ef0300000000000009000000
214+0000000000000000000000006e6574207469746c650000001c00bf0001005000
215+f303000000000000000000000000000000000000000000000108c00003000000
216+ef03000000000000090000000000000000000000000000006e6574207469746c
217+650000001c00c00001005000f303000000000000000000000000000000000000
218+000000000108c10003000000ef03000000000000090000000000000000000000
219+000000006e6574207469746c650000001c00c10001005000f303000000000000
220+000000000000000000000000000000000108c20003000000ef03000000000000
221+090000000000000000000000000000006e6574207469746c650000001c00c200
222+01005000f303000000000000000000000000000000000000000000000108c300
223+03000000ef03000000000000090000000000000000000000000000006e657420
224+7469746c650000001c00c30001005000f3030000000000000000000000000000
225+00000000000000000108c40003000000ef030000000000000900000000000000
226+00000000000000006e6574207469746c650000001c00c40001005000f3030000
227+00000000000000000000000000000000000000000108c50003000000ef030000
228+00000000090000000000000000000000000000006e6574207469746c65000000
229+1c00c50001005000f30300000000000000000000000000000000000000000000
230+0108c60003000000ef0300000000000009000000000000000000000000000000
231+6e6574207469746c650000001c00c60001005000f30300000000000000000000
232+0000000000000000000000000108c70003000000ef0300000000000009000000
233+0000000000000000000000006e6574207469746c650000001c00c70001005000
234+f303000000000000000000000000000000000000000000000108c80003000000
235+ef03000000000000090000000000000000000000000000006e6574207469746c
236+650000001c00c80001005000f303000000000000000000000000000000000000
237+000000000108c90003000000ef03000000000000090000000000000000000000
238+000000006e6574207469746c650000001c00c90001005000f303000000000000
239+000000000000000000000000000000000108ca0003000000ef03000000000000
240+090000000000000000000000000000006e6574207469746c650000001c00ca00
241+01005000f303000000000000000000000000000000000000000000000108cb00
242+03000000ef03000000000000090000000000000000000000000000006e657420
243+7469746c650000001c00cb0001005000f3030000000000000000000000000000
244+00000000000000000108cc0003000000ef030000000000000900000000000000
245+00000000000000006e6574207469746c650000001c00cc0001005000f3030000
246+00000000000000000000000000000000000000000108cd0003000000ef030000
247+00000000090000000000000000000000000000006e6574207469746c65000000
248+1c00cd0001005000f30300000000000000000000000000000000000000000000
249+0108ce0003000000ef0300000000000009000000000000000000000000000000
250+6e6574207469746c650000001c00ce0001005000f30300000000000000000000
251+0000000000000000000000000108cf0003000000ef0300000000000009000000
252+0000000000000000000000006e6574207469746c650000001c00cf0001005000
253+f303000000000000000000000000000000000000000000000108d00003000000
254+ef03000000000000090000000000000000000000000000006e6574207469746c
255+650000001c00d00001005000f303000000000000000000000000000000000000
256+000000000108d10003000000ef03000000000000090000000000000000000000
257+000000006e6574207469746c650000001c00d10001005000f303000000000000
258+000000000000000000000000000000000108d20003000000ef03000000000000
259+090000000000000000000000000000006e6574207469746c650000001c00d200
260+01005000f303000000000000000000000000000000000000000000000108d300
261+03000000ef03000000000000090000000000000000000000000000006e657420
262+7469746c650000001c00d30001005000f3030000000000000000000000000000
263+00000000000000000108d40003000000ef030000000000000900000000000000
264+00000000000000006e6574207469746c650000001c00d40001005000f3030000
265+00000000000000000000000000000000000000000108d50003000000ef030000
266+00000000090000000000000000000000000000006e6574207469746c65000000
267+1c00d50001005000f30300000000000000000000000000000000000000000000
268+0108d60003000000ef0300000000000009000000000000000000000000000000
269+6e6574207469746c650000001c00d60001005000f30300000000000000000000
270+0000000000000000000000000108d70003000000ef0300000000000009000000
271+0000000000000000000000006e6574207469746c650000001c00d70001005000
272+f303000000000000000000000000000000000000000000000108d80003000000
273+ef03000000000000090000000000000000000000000000006e6574207469746c
274+650000001c00d80001005000f303000000000000000000000000000000000000
275+000000000108d90003000000ef03000000000000090000000000000000000000
276+000000006e6574207469746c650000001c00d90001005000f303000000000000
277+000000000000000000000000000000000108da0003000000ef03000000000000
278+090000000000000000000000000000006e6574207469746c650000001c00da00
279+01005000f303000000000000000000000000000000000000000000000108db00
280+03000000ef03000000000000090000000000000000000000000000006e657420
281+7469746c650000001c00db0001005000f3030000000000000000000000000000
282+00000000000000000108dc0003000000ef030000000000000900000000000000
283+00000000000000006e6574207469746c650000001c00dc0001005000f3030000
284+00000000000000000000000000000000000000000108dd0003000000ef030000
285+00000000090000000000000000000000000000006e6574207469746c65000000
286+1c00dd0001005000f30300000000000000000000000000000000000000000000
287+0108de0003000000ef0300000000000009000000000000000000000000000000
288+6e6574207469746c650000001c00de0001005000f30300000000000000000000
289+0000000000000000000000000108df0003000000ef0300000000000009000000
290+0000000000000000000000006e6574207469746c650000001c00df0001005000
291+f303000000000000000000000000000000000000000000000108e00003000000
292+ef03000000000000090000000000000000000000000000006e6574207469746c
293+650000001c00e00001005000f303000000000000000000000000000000000000
294+000000000108e10003000000ef03000000000000090000000000000000000000
295+000000006e6574207469746c650000001c00e10001005000f303000000000000
296+000000000000000000000000000000000108e20003000000ef03000000000000
297+090000000000000000000000000000006e6574207469746c650000001c00e200
298+01005000f303000000000000000000000000000000000000000000000108e300
299+03000000ef03000000000000090000000000000000000000000000006e657420
300+7469746c650000001c00e30001005000f3030000000000000000000000000000
301+00000000000000000108e40003000000ef030000000000000900000000000000
302+00000000000000006e6574207469746c650000001c00e40001005000f3030000
303+00000000000000000000000000000000000000000108e50003000000ef030000
304+00000000090000000000000000000000000000006e6574207469746c65000000
305+1c00e50001005000f30300000000000000000000000000000000000000000000
306+0108e60003000000ef0300000000000009000000000000000000000000000000
307+6e6574207469746c650000001c00e60001005000f30300000000000000000000
308+0000000000000000000000000108e70003000000ef0300000000000009000000
309+0000000000000000000000006e6574207469746c650000001c00e70001005000
310+f303000000000000000000000000000000000000000000000108e80003000000
311+ef03000000000000090000000000000000000000000000006e6574207469746c
312+650000001c00e80001005000f303000000000000000000000000000000000000
313+000000000108e90003000000ef03000000000000090000000000000000000000
314+000000006e6574207469746c650000001c00e90001005000f303000000000000
315+000000000000000000000000000000000108ea0003000000ef03000000000000
316+090000000000000000000000000000006e6574207469746c650000001c00ea00
317+01005000f303000000000000000000000000000000000000000000000108eb00
318+03000000ef03000000000000090000000000000000000000000000006e657420
319+7469746c650000001c00eb0001005000f3030000000000000000000000000000
320+00000000000000000108ec0003000000ef030000000000000900000000000000
321+00000000000000006e6574207469746c650000001c00ec0001005000f3030000
322+00000000000000000000000000000000000000000108ed0003000000ef030000
323+00000000090000000000000000000000000000006e6574207469746c65000000
324+1c00ed0001005000f30300000000000000000000000000000000000000000000
325+0108ee0003000000ef0300000000000009000000000000000000000000000000
326+6e6574207469746c650000001c00ee0001005000f30300000000000000000000
327+0000000000000000000000000108ef0003000000ef0300000000000009000000
328+0000000000000000000000006e6574207469746c650000001c00ef0001005000
329+f303000000000000000000000000000000000000000000000108f00003000000
330+ef03000000000000090000000000000000000000000000006e6574207469746c
331+650000001c00f00001005000f303000000000000000000000000000000000000
332+000000000108f10003000000ef03000000000000090000000000000000000000
333+000000006e6574207469746c650000001c00f10001005000f303000000000000
334+000000000000000000000000000000000108f20003000000ef03000000000000
335+090000000000000000000000000000006e6574207469746c650000001c00f200
336+01005000f303000000000000000000000000000000000000000000000108f300
337+03000000ef03000000000000090000000000000000000000000000006e657420
338+7469746c650000001c00f30001005000f3030000000000000000000000000000
339+00000000000000000108f40003000000ef030000000000000900000000000000
340+00000000000000006e6574207469746c650000001c00f40001005000f3030000
341+00000000000000000000000000000000000000000108f50003000000ef030000
342+00000000090000000000000000000000000000006e6574207469746c65000000
343+1c00f50001005000f30300000000000000000000000000000000000000000000
344+0108f60003000000ef0300000000000009000000000000000000000000000000
345+6e6574207469746c650000001c00f60001005000f30300000000000000000000
346+0000000000000000000000000108f70003000000ef0300000000000009000000
347+0000000000000000000000006e6574207469746c650000001c00f70001005000
348+f303000000000000000000000000000000000000000000000108f80003000000
349+ef03000000000000090000000000000000000000000000006e6574207469746c
350+650000001c00f80001005000f303000000000000000000000000000000000000
351+000000000108f90003000000ef03000000000000090000000000000000000000
352+000000006e6574207469746c650000001c00f90001005000f303000000000000
353+000000000000000000000000000000000108fa0003000000ef03000000000000
354+090000000000000000000000000000006e6574207469746c650000001c00fa00
355+01005000f303000000000000000000000000000000000000000000000108fb00
356+03000000ef03000000000000090000000000000000000000000000006e657420
357+7469746c650000001c00fb0001005000f3030000000000000000000000000000
358+00000000000000000108fc0003000000ef030000000000000900000000000000
359+00000000000000006e6574207469746c650000001c00fc0001005000f3030000
360+00000000000000000000000000000000000000000108fd0003000000ef030000
361+00000000090000000000000000000000000000006e6574207469746c65000000
362+1c00fd0001005000f30300000000000000000000000000000000000000000000
363+0108fe0003000000ef0300000000000009000000000000000000000000000000
364+6e6574207469746c650000001c00fe0001005000f30300000000000000000000
365+0000000000000000000000000108ff0003000000ef0300000000000009000000
366+0000000000000000000000006e6574207469746c650000001c00ff0001005000
367+f303000000000000000000000000000000000000000000000108000103000000
368+ef03000000000000090000000000000000000000000000006e6574207469746c
369+650000001c00000101005000f303000000000000000000000000000000000000
370+000000000108010103000000ef03000000000000090000000000000000000000
371+000000006e6574207469746c650000001c00010101005000f303000000000000
372+000000000000000000000000000000000108020103000000ef03000000000000
373+090000000000000000000000000000006e6574207469746c650000001c000201
374+01005000f3030000000000000000000000000000000000000000000001080301
375+03000000ef03000000000000090000000000000000000000000000006e657420
376+7469746c650000001c00030101005000f3030000000000000000000000000000
377+00000000000000000108040103000000ef030000000000000900000000000000
378+00000000000000006e6574207469746c650000001c00040101005000f3030000
379+00000000000000000000000000000000000000000108050103000000ef030000
380+00000000090000000000000000000000000000006e6574207469746c65000000
381+1c00050101005000f30300000000000000000000000000000000000000000000
382+0108060103000000ef0300000000000009000000000000000000000000000000
383+6e6574207469746c650000001c00060101005000f30300000000000000000000
384+0000000000000000000000000108070103000000ef0300000000000009000000
385+0000000000000000000000006e6574207469746c650000001c00070101005000
386+f303000000000000000000000000000000000000000000000108080103000000
387+ef03000000000000090000000000000000000000000000006e6574207469746c
388+650000001c00080101005000f303000000000000000000000000000000000000
389+000000000108090103000000ef03000000000000090000000000000000000000
390+000000006e6574207469746c650000001c00090101005000f303000000000000
391+0000000000000000000000000000000001080a0103000000ef03000000000000
392+090000000000000000000000000000006e6574207469746c650000001c000a01
393+01005000f3030000000000000000000000000000000000000000000001080b01
394+03000000ef03000000000000090000000000000000000000000000006e657420
395+7469746c650000001c000b0101005000f3030000000000000000000000000000
396+000000000000000001080c0103000000ef030000000000000900000000000000
397+00000000000000006e6574207469746c650000001c000c0101005000f3030000
398+000000000000000000000000000000000000000001080d0103000000ef030000
399+00000000090000000000000000000000000000006e6574207469746c65000000
400+1c000d0101005000f30300000000000000000000000000000000000000000000
401+01080e0103000000ef0300000000000009000000000000000000000000000000
402+6e6574207469746c650000001c000e0101005000f30300000000000000000000
403+00000000000000000000000001080f0103000000ef0300000000000009000000
404+0000000000000000000000006e6574207469746c650000001c000f0101005000
405+f303000000000000000000000000000000000000000000000108100103000000
406+ef03000000000000090000000000000000000000000000006e6574207469746c
407+650000001c00100101005000f303000000000000000000000000000000000000
408+000000000108110103000000ef03000000000000090000000000000000000000
409+000000006e6574207469746c650000001c00110101005000f303000000000000
410+000000000000000000000000000000000108120103000000ef03000000000000
411+090000000000000000000000000000006e6574207469746c650000001c001201
412+01005000f3030000000000000000000000000000000000000000000001081301
413+03000000ef03000000000000090000000000000000000000000000006e657420
414+7469746c650000001c00130101005000f3030000000000000000000000000000
415+00000000000000000108140103000000ef030000000000000900000000000000
416+00000000000000006e6574207469746c650000001c00140101005000f3030000
417+00000000000000000000000000000000000000000108150103000000ef030000
418+00000000090000000000000000000000000000006e6574207469746c65000000
419+1c00150101005000f30300000000000000000000000000000000000000000000
420+0108160103000000ef0300000000000009000000000000000000000000000000
421+6e6574207469746c650000001c00160101005000f30300000000000000000000
422+0000000000000000000000000108170103000000ef0300000000000009000000
423+0000000000000000000000006e6574207469746c650000001c00170101005000
424+f303000000000000000000000000000000000000000000000108180103000000
425+ef03000000000000090000000000000000000000000000006e6574207469746c
426+650000001c00180101005000f303000000000000000000000000000000000000
427+000000000108190103000000ef03000000000000090000000000000000000000
428+000000006e6574207469746c650000001c00190101005000f303000000000000
429+0000000000000000000000000000000001081a0103000000ef03000000000000
430+090000000000000000000000000000006e6574207469746c650000001c001a01
431+01005000f3030000000000000000000000000000000000000000000001081b01
432+03000000ef03000000000000090000000000000000000000000000006e657420
433+7469746c650000001c001b0101005000f3030000000000000000000000000000
434+000000000000000001081c0103000000ef030000000000000900000000000000
435+00000000000000006e6574207469746c650000001c001c0101005000f3030000
436+000000000000000000000000000000000000000001081d0103000000ef030000
437+00000000090000000000000000000000000000006e6574207469746c65000000
438+1c001d0101005000f30300000000000000000000000000000000000000000000
439+01081e0103000000ef0300000000000009000000000000000000000000000000
440+6e6574207469746c650000001c001e0101005000f30300000000000000000000
441+00000000000000000000000001081f0103000000ef0300000000000009000000
442+0000000000000000000000006e6574207469746c650000001c001f0101005000
443+f303000000000000000000000000000000000000000000000108200103000000
444+ef03000000000000090000000000000000000000000000006e6574207469746c
445+650000001c00200101005000f303000000000000000000000000000000000000
446+000000000108210103000000ef03000000000000090000000000000000000000
447+000000006e6574207469746c650000001c00210101005000f303000000000000
448+000000000000000000000000000000000108220103000000ef03000000000000
449+090000000000000000000000000000006e6574207469746c650000001c002201
450+01005000f3030000000000000000000000000000000000000000000001082301
451+03000000ef03000000000000090000000000000000000000000000006e657420
452+7469746c650000001c00230101005000f3030000000000000000000000000000
453+00000000000000000108240103000000ef030000000000000900000000000000
454+00000000000000006e6574207469746c650000001c00240101005000f3030000
455+00000000000000000000000000000000000000000108250103000000ef030000
456+00000000090000000000000000000000000000006e6574207469746c65000000
457+1c00250101005000f30300000000000000000000000000000000000000000000
458+0108260103000000ef0300000000000009000000000000000000000000000000
459+6e6574207469746c650000001c00260101005000f30300000000000000000000
460+0000000000000000000000000108270103000000ef0300000000000009000000
461+0000000000000000000000006e6574207469746c650000001c00270101005000
462+f303000000000000000000000000000000000000000000000108280103000000
463+ef03000000000000090000000000000000000000000000006e6574207469746c
464+650000001c00280101005000f303000000000000000000000000000000000000
465+000000000108290103000000ef03000000000000090000000000000000000000
466+000000006e6574207469746c650000001c00290101005000f303000000000000
467+0000000000000000000000000000000001082a0103000000ef03000000000000
468+090000000000000000000000000000006e6574207469746c650000001c002a01
469+01005000f3030000000000000000000000000000000000000000000001082b01
470+03000000ef03000000000000090000000000000000000000000000006e657420
471+7469746c650000001c002b0101005000f3030000000000000000000000000000
472+000000000000000001082c0103000000ef030000000000000900000000000000
473+00000000000000006e6574207469746c650000001c002c0101005000f3030000
474+000000000000000000000000000000000000000001082d0103000000ef030000
475+00000000090000000000000000000000000000006e6574207469746c65000000
476+1c002d0101005000f30300000000000000000000000000000000000000000000
477+01082e0103000000ef0300000000000009000000000000000000000000000000
478+6e6574207469746c650000001c002e0101005000f30300000000000000000000
479+00000000000000000000000001082f0103000000ef0300000000000009000000
480+0000000000000000000000006e6574207469746c650000001c002f0101005000
481+f303000000000000000000000000000000000000000000000108300103000000
482+ef03000000000000090000000000000000000000000000006e6574207469746c
483+650000001c00300101005000f303000000000000000000000000000000000000
484+000000000108310103000000ef03000000000000090000000000000000000000
485+000000006e6574207469746c650000001c00310101005000f303000000000000
486+000000000000000000000000000000000108320103000000ef03000000000000
487+090000000000000000000000000000006e6574207469746c650000001c003201
488+01005000f3030000000000000000000000000000000000000000000001083301
489+03000000ef03000000000000090000000000000000000000000000006e657420
490+7469746c650000001c00330101005000f3030000000000000000000000000000
491+00000000000000000108340103000000ef030000000000000900000000000000
492+00000000000000006e6574207469746c650000001c00340101005000f3030000
493+00000000000000000000000000000000000000000108350103000000ef030000
494+00000000090000000000000000000000000000006e6574207469746c65000000
495+1c00350101005000f30300000000000000000000000000000000000000000000
496+0108360103000000ef0300000000000009000000000000000000000000000000
497+6e6574207469746c650000001c00360101005000f30300000000000000000000
498+0000000000000000000000000108370103000000ef0300000000000009000000
499+0000000000000000000000006e6574207469746c650000001c00370101005000
500+f303000000000000000000000000000000000000000000000108380103000000
501+ef03000000000000090000000000000000000000000000006e6574207469746c
502+650000001c00380101005000f303000000000000000000000000000000000000
503+000000000108390103000000ef03000000000000090000000000000000000000
504+000000006e6574207469746c650000001c00390101005000f303000000000000
505+0000000000000000000000000000000001083a0103000000ef03000000000000
506+090000000000000000000000000000006e6574207469746c650000001c003a01
507+01005000f3030000000000000000000000000000000000000000000001083b01
508+03000000ef03000000000000090000000000000000000000000000006e657420
509+7469746c650000001c003b0101005000f3030000000000000000000000000000
510+000000000000000001083c0103000000ef030000000000000900000000000000
511+00000000000000006e6574207469746c650000001c003c0101005000f3030000
512+000000000000000000000000000000000000000001083d0103000000ef030000
513+00000000090000000000000000000000000000006e6574207469746c65000000
514+1c003d0101005000f30300000000000000000000000000000000000000000000
515+01083e0103000000ef0300000000000009000000000000000000000000000000
516+6e6574207469746c650000001c003e0101005000f30300000000000000000000
517+00000000000000000000000001083f0103000000ef0300000000000009000000
518+0000000000000000000000006e6574207469746c650000001c003f0101005000
519+f303000000000000000000000000000000000000000000000108400103000000
520+ef03000000000000090000000000000000000000000000006e6574207469746c
521+650000001c00400101005000f303000000000000000000000000000000000000
522+000000000108410103000000ef03000000000000090000000000000000000000
523+000000006e6574207469746c650000001c00410101005000f303000000000000
524+000000000000000000000000000000000108420103000000ef03000000000000
525+090000000000000000000000000000006e6574207469746c650000001c004201
526+01005000f3030000000000000000000000000000000000000000000001084301
527+03000000ef03000000000000090000000000000000000000000000006e657420
528+7469746c650000001c00430101005000f3030000000000000000000000000000
529+00000000000000000108440103000000ef030000000000000900000000000000
530+00000000000000006e6574207469746c650000001c00440101005000f3030000
531+00000000000000000000000000000000000000000108450103000000ef030000
532+00000000090000000000000000000000000000006e6574207469746c65000000
533+1c00450101005000f30300000000000000000000000000000000000000000000
534+0108460103000000ef0300000000000009000000000000000000000000000000
535+6e6574207469746c650000001c00460101005000f30300000000000000000000
536+0000000000000000000000000108470103000000ef0300000000000009000000
537+0000000000000000000000006e6574207469746c650000001c00470101005000
538+f303000000000000000000000000000000000000000000000108480103000000
539+ef03000000000000090000000000000000000000000000006e6574207469746c
540+650000001c00480101005000f303000000000000000000000000000000000000
541+000000000108490103000000ef03000000000000090000000000000000000000
542+000000006e6574207469746c650000001c00490101005000f303000000000000
543+0000000000000000000000000000000001084a0103000000ef03000000000000
544+090000000000000000000000000000006e6574207469746c650000001c004a01
545+01005000f3030000000000000000000000000000000000000000000001084b01
546+03000000ef03000000000000090000000000000000000000000000006e657420
547+7469746c650000001c004b0101005000f3030000000000000000000000000000
548+000000000000000001084c0103000000ef030000000000000900000000000000
549+00000000000000006e6574207469746c650000001c004c0101005000f3030000
550+000000000000000000000000000000000000000001084d0103000000ef030000
551+00000000090000000000000000000000000000006e6574207469746c65000000
552+1c004d0101005000f30300000000000000000000000000000000000000000000
553+01084e0103000000ef0300000000000009000000000000000000000000000000
554+6e6574207469746c650000001c004e0101005000f30300000000000000000000
555+00000000000000000000000001084f0103000000ef0300000000000009000000
556+0000000000000000000000006e6574207469746c650000001c004f0101005000
557+f303000000000000000000000000000000000000000000000108500103000000
558+ef03000000000000090000000000000000000000000000006e6574207469746c
559+650000001c00500101005000f303000000000000000000000000000000000000
560+000000000108510103000000ef03000000000000090000000000000000000000
561+000000006e6574207469746c650000001c00510101005000f303000000000000
562+000000000000000000000000000000000108520103000000ef03000000000000
563+090000000000000000000000000000006e6574207469746c650000001c005201
564+01005000f3030000000000000000000000000000000000000000000001085301
565+03000000ef03000000000000090000000000000000000000000000006e657420
566+7469746c650000001c00530101005000f3030000000000000000000000000000
567+00000000000000000108540103000000ef030000000000000900000000000000
568+00000000000000006e6574207469746c650000001c00540101005000f3030000
569+00000000000000000000000000000000000000000108550103000000ef030000
570+00000000090000000000000000000000000000006e6574207469746c65000000
571+1c00550101005000f30300000000000000000000000000000000000000000000
572+0108560103000000ef0300000000000009000000000000000000000000000000
573+6e6574207469746c650000001c00560101005000f30300000000000000000000
574+0000000000000000000000000108570103000000ef0300000000000009000000
575+0000000000000000000000006e6574207469746c650000001c00570101005000
576+f303000000000000000000000000000000000000000000000108580103000000
577+ef03000000000000090000000000000000000000000000006e6574207469746c
578+650000001c00580101005000f303000000000000000000000000000000000000
579+000000000108590103000000ef03000000000000090000000000000000000000
580+000000006e6574207469746c650000001c00590101005000f303000000000000
581+0000000000000000000000000000000001085a0103000000ef03000000000000
582+090000000000000000000000000000006e6574207469746c650000001c005a01
583+01005000f3030000000000000000000000000000000000000000000001085b01
584+03000000ef03000000000000090000000000000000000000000000006e657420
585+7469746c650000001c005b0101005000f3030000000000000000000000000000
586+000000000000000001085c0103000000ef030000000000000900000000000000
587+00000000000000006e6574207469746c650000001c005c0101005000f3030000
588+000000000000000000000000000000000000000001085d0103000000ef030000
589+00000000090000000000000000000000000000006e6574207469746c65000000
590+1c005d0101005000f30300000000000000000000000000000000000000000000
591+01085e0103000000ef0300000000000009000000000000000000000000000000
592+6e6574207469746c650000001c005e0101005000f30300000000000000000000
593+00000000000000000000000001085f0103000000ef0300000000000009000000
594+0000000000000000000000006e6574207469746c650000001c005f0101005000
595+f303000000000000000000000000000000000000000000000108600103000000
596+ef03000000000000090000000000000000000000000000006e6574207469746c
597+650000001c00600101005000f303000000000000000000000000000000000000
598+000000000108610103000000ef03000000000000090000000000000000000000
599+000000006e6574207469746c650000001c00610101005000f303000000000000
600+000000000000000000000000000000000108620103000000ef03000000000000
601+090000000000000000000000000000006e6574207469746c650000001c006201
602+01005000f3030000000000000000000000000000000000000000000001086301
603+03000000ef03000000000000090000000000000000000000000000006e657420
604+7469746c650000001c00630101005000f3030000000000000000000000000000
605+00000000000000000108640103000000ef030000000000000900000000000000
606+00000000000000006e6574207469746c650000001c00640101005000f3030000
607+00000000000000000000000000000000000000000108650103000000ef030000
608+00000000090000000000000000000000000000006e6574207469746c65000000
609+1c00650101005000f30300000000000000000000000000000000000000000000
610+0108660103000000ef0300000000000009000000000000000000000000000000
611+6e6574207469746c650000001c00660101005000f30300000000000000000000
612+0000000000000000000000000108670103000000ef0300000000000009000000
613+0000000000000000000000006e6574207469746c650000001c00670101005000
614+f303000000000000000000000000000000000000000000000108680103000000
615+ef03000000000000090000000000000000000000000000006e6574207469746c
616+650000001c00680101005000f303000000000000000000000000000000000000
617+000000000108690103000000ef03000000000000090000000000000000000000
618+000000006e6574207469746c650000001c00690101005000f303000000000000
619+0000000000000000000000000000000001086a0103000000ef03000000000000
620+090000000000000000000000000000006e6574207469746c650000001c006a01
621+01005000f3030000000000000000000000000000000000000000000001086b01
622+03000000ef03000000000000090000000000000000000000000000006e657420
623+7469746c650000001c006b0101005000f3030000000000000000000000000000
624+000000000000000001086c0103000000ef030000000000000900000000000000
625+00000000000000006e6574207469746c650000001c006c0101005000f3030000
626+000000000000000000000000000000000000000001086d0103000000ef030000
627+00000000090000000000000000000000000000006e6574207469746c65000000
628+1c006d0101005000f30300000000000000000000000000000000000000000000
629+01086e0103000000ef0300000000000009000000000000000000000000000000
630+6e6574207469746c650000001c006e0101005000f30300000000000000000000
631+00000000000000000000000001086f0103000000ef0300000000000009000000
632+0000000000000000000000006e6574207469746c650000001c006f0101005000
633+f303000000000000000000000000000000000000000000000108700103000000
634+ef03000000000000090000000000000000000000000000006e6574207469746c
635+650000001c00700101005000f303000000000000000000000000000000000000
636+000000000108710103000000ef03000000000000090000000000000000000000
637+000000006e6574207469746c650000001c00710101005000f303000000000000
638+000000000000000000000000000000000108720103000000ef03000000000000
639+090000000000000000000000000000006e6574207469746c650000001c007201
640+01005000f3030000000000000000000000000000000000000000000001087301
641+03000000ef03000000000000090000000000000000000000000000006e657420
642+7469746c650000001c00730101005000f3030000000000000000000000000000
643+00000000000000000108740103000000ef030000000000000900000000000000
644+00000000000000006e6574207469746c650000001c00740101005000f3030000
645+00000000000000000000000000000000000000000108750103000000ef030000
646+00000000090000000000000000000000000000006e6574207469746c65000000
647+1c00750101005000f30300000000000000000000000000000000000000000000
648+0108760103000000ef0300000000000009000000000000000000000000000000
649+6e6574207469746c650000001c00760101005000f30300000000000000000000
650+0000000000000000000000000108770103000000ef0300000000000009000000
651+0000000000000000000000006e6574207469746c650000001c00770101005000
652+f303000000000000000000000000000000000000000000000108780103000000
653+ef03000000000000090000000000000000000000000000006e6574207469746c
654+650000001c00780101005000f303000000000000000000000000000000000000
655+000000000108790103000000ef03000000000000090000000000000000000000
656+000000006e6574207469746c650000001c00790101005000f303000000000000
657+0000000000000000000000000000000001087a0103000000ef03000000000000
658+090000000000000000000000000000006e6574207469746c650000001c007a01
659+01005000f3030000000000000000000000000000000000000000000001087b01
660+03000000ef03000000000000090000000000000000000000000000006e657420
661+7469746c650000001c007b0101005000f3030000000000000000000000000000
662+000000000000000001087c0103000000ef030000000000000900000000000000
663+00000000000000006e6574207469746c650000001c007c0101005000f3030000
664+000000000000000000000000000000000000000001087d0103000000ef030000
665+00000000090000000000000000000000000000006e6574207469746c65000000
666+1c007d0101005000f30300000000000000000000000000000000000000000000
667+01087e0103000000ef0300000000000009000000000000000000000000000000
668+6e6574207469746c650000001c007e0101005000f30300000000000000000000
669+00000000000000000000000001087f0103000000ef0300000000000009000000
670+0000000000000000000000006e6574207469746c650000001c007f0101005000
671+f303000000000000000000000000000000000000000000000108800103000000
672+ef03000000000000090000000000000000000000000000006e6574207469746c
673+650000001c00800101005000f303000000000000000000000000000000000000
674+000000000108810103000000ef03000000000000090000000000000000000000
675+000000006e6574207469746c650000001c00810101005000f303000000000000
676+000000000000000000000000000000000108820103000000ef03000000000000
677+090000000000000000000000000000006e6574207469746c650000001c008201
678+01005000f3030000000000000000000000000000000000000000000001088301
679+03000000ef03000000000000090000000000000000000000000000006e657420
680+7469746c650000001c00830101005000f3030000000000000000000000000000
681+00000000000000000108840103000000ef030000000000000900000000000000
682+00000000000000006e6574207469746c650000001c00840101005000f3030000
683+00000000000000000000000000000000000000000108850103000000ef030000
684+00000000090000000000000000000000000000006e6574207469746c65000000
685+1c00850101005000f30300000000000000000000000000000000000000000000
686+0108860103000000ef0300000000000009000000000000000000000000000000
687+6e6574207469746c650000001c00860101005000f30300000000000000000000
688+0000000000000000000000000108870103000000ef0300000000000009000000
689+0000000000000000000000006e6574207469746c650000001c00870101005000
690+f303000000000000000000000000000000000000000000000108880103000000
691+ef03000000000000090000000000000000000000000000006e6574207469746c
692+650000001c00880101005000f303000000000000000000000000000000000000
693+000000000108890103000000ef03000000000000090000000000000000000000
694+000000006e6574207469746c650000001c00890101005000f303000000000000
695+0000000000000000000000000000000001088a0103000000ef03000000000000
696+090000000000000000000000000000006e6574207469746c650000001c008a01
697+01005000f3030000000000000000000000000000000000000000000001088b01
698+03000000ef03000000000000090000000000000000000000000000006e657420
699+7469746c650000001c008b0101005000f3030000000000000000000000000000
700+000000000000000001088c0103000000ef030000000000000900000000000000
701+00000000000000006e6574207469746c650000001c008c0101005000f3030000
702+000000000000000000000000000000000000000001088d0103000000ef030000
703+00000000090000000000000000000000000000006e6574207469746c65000000
704+1c008d0101005000f30300000000000000000000000000000000000000000000
705+01088e0103000000ef0300000000000009000000000000000000000000000000
706+6e6574207469746c650000001c008e0101005000f30300000000000000000000
707+00000000000000000000000001088f0103000000ef0300000000000009000000
708+0000000000000000000000006e6574207469746c650000001c008f0101005000
709+f303000000000000000000000000000000000000000000000108900103000000
710+ef03000000000000090000000000000000000000000000006e6574207469746c
711+650000001c00900101005000f303000000000000000000000000000000000000
712+000000000108910103000000ef03000000000000090000000000000000000000
713+000000006e6574207469746c650000001c00910101005000f303000000000000
714+000000000000000000000000000000000108920103000000ef03000000000000
715+090000000000000000000000000000006e6574207469746c650000001c009201
716+01005000f3030000000000000000000000000000000000000000000001089301
717+03000000ef03000000000000090000000000000000000000000000006e657420
718+7469746c650000001c00930101005000f3030000000000000000000000000000
719+00000000000000000108940103000000ef030000000000000900000000000000
720+00000000000000006e6574207469746c650000001c00940101005000f3030000
721+00000000000000000000000000000000000000000108950103000000ef030000
722+00000000090000000000000000000000000000006e6574207469746c65000000
723+1c00950101005000f30300000000000000000000000000000000000000000000
724+0108960103000000ef0300000000000009000000000000000000000000000000
725+6e6574207469746c650000001c00960101005000f30300000000000000000000
726+0000000000000000000000000108970103000000ef0300000000000009000000
727+0000000000000000000000006e6574207469746c650000001c00970101005000
728+f303000000000000000000000000000000000000000000000108980103000000
729+ef03000000000000090000000000000000000000000000006e6574207469746c
730+650000001c00980101005000f303000000000000000000000000000000000000
731+000000000108990103000000ef03000000000000090000000000000000000000
732+000000006e6574207469746c650000001c00990101005000f303000000000000
733+0000000000000000000000000000000001089a0103000000ef03000000000000
734+090000000000000000000000000000006e6574207469746c650000001c009a01
735+01005000f3030000000000000000000000000000000000000000000001089b01
736+03000000ef03000000000000090000000000000000000000000000006e657420
737+7469746c650000001c009b0101005000f3030000000000000000000000000000
738+000000000000000001089c0103000000ef030000000000000900000000000000
739+00000000000000006e6574207469746c650000001c009c0101005000f3030000
740+000000000000000000000000000000000000000001089d0103000000ef030000
741+00000000090000000000000000000000000000006e6574207469746c65000000
742+1c009d0101005000f30300000000000000000000000000000000000000000000
743+01089e0103000000ef0300000000000009000000000000000000000000000000
744+6e6574207469746c650000001c009e0101005000f30300000000000000000000
745+00000000000000000000000001089f0103000000ef0300000000000009000000
746+0000000000000000000000006e6574207469746c650000001c009f0101005000
747+f303000000000000000000000000000000000000000000000108a00103000000
748+ef03000000000000090000000000000000000000000000006e6574207469746c
749+650000001c00a00101005000f303000000000000000000000000000000000000
750+000000000108a10103000000ef03000000000000090000000000000000000000
751+000000006e6574207469746c650000001c00a10101005000f303000000000000
752+000000000000000000000000000000000108a20103000000ef03000000000000
753+090000000000000000000000000000006e6574207469746c650000001c00a201
754+01005000f303000000000000000000000000000000000000000000000108a301
755+03000000ef03000000000000090000000000000000000000000000006e657420
756+7469746c650000001c00a30101005000f3030000000000000000000000000000
757+00000000000000000108a40103000000ef030000000000000900000000000000
758+00000000000000006e6574207469746c650000001c00a40101005000f3030000
759+00000000000000000000000000000000000000000108a50103000000ef030000
760+00000000090000000000000000000000000000006e6574207469746c65000000
761+1c00a50101005000f30300000000000000000000000000000000000000000000
762+0108a60103000000ef0300000000000009000000000000000000000000000000
763+6e6574207469746c650000001c00a60101005000f30300000000000000000000
764+0000000000000000000000000108a70103000000ef0300000000000009000000
765+0000000000000000000000006e6574207469746c650000001c00a70101005000
766+f303000000000000000000000000000000000000000000000108a80103000000
767+ef03000000000000090000000000000000000000000000006e6574207469746c
768+650000001c00a80101005000f303000000000000000000000000000000000000
769+000000000108a90103000000ef03000000000000090000000000000000000000
770+000000006e6574207469746c650000001c00a90101005000f303000000000000
771+000000000000000000000000000000000108aa0103000000ef03000000000000
772+090000000000000000000000000000006e6574207469746c650000001c00aa01
773+01005000f303000000000000000000000000000000000000000000000108ab01
774+03000000ef03000000000000090000000000000000000000000000006e657420
775+7469746c650000001c00ab0101005000f3030000000000000000000000000000
776+00000000000000000108ac0103000000ef030000000000000900000000000000
777+00000000000000006e6574207469746c650000001c00ac0101005000f3030000
778+00000000000000000000000000000000000000000108ad0103000000ef030000
779+00000000090000000000000000000000000000006e6574207469746c65000000
780+1c00ad0101005000f30300000000000000000000000000000000000000000000
781+0108ae0103000000ef0300000000000009000000000000000000000000000000
782+6e6574207469746c650000001c00ae0101005000f30300000000000000000000
783+0000000000000000000000000108af0103000000ef0300000000000009000000
784+0000000000000000000000006e6574207469746c650000001c00af0101005000
785+f303000000000000000000000000000000000000000000000108b00103000000
786+ef03000000000000090000000000000000000000000000006e6574207469746c
787+650000001c00b00101005000f303000000000000000000000000000000000000
788+000000000108b10103000000ef03000000000000090000000000000000000000
789+000000006e6574207469746c650000001c00b10101005000f303000000000000
790+000000000000000000000000000000000108b20103000000ef03000000000000
791+090000000000000000000000000000006e6574207469746c650000001c00b201
792+01005000f303000000000000000000000000000000000000000000000108b301
793+03000000ef03000000000000090000000000000000000000000000006e657420
794+7469746c650000001c00b30101005000f3030000000000000000000000000000
795+00000000000000000108b40103000000ef030000000000000900000000000000
796+00000000000000006e6574207469746c650000001c00b40101005000f3030000
797+00000000000000000000000000000000000000000108b50103000000ef030000
798+00000000090000000000000000000000000000006e6574207469746c65000000
799+1c00b50101005000f30300000000000000000000000000000000000000000000
800+0108b60103000000ef0300000000000009000000000000000000000000000000
801+6e6574207469746c650000001c00b60101005000f30300000000000000000000
802+0000000000000000000000000108b70103000000ef0300000000000009000000
803+0000000000000000000000006e6574207469746c650000001c00b70101005000
804+f303000000000000000000000000000000000000000000000108b80103000000
805+ef03000000000000090000000000000000000000000000006e6574207469746c
806+650000001c00b80101005000f303000000000000000000000000000000000000
807+000000000108b90103000000ef03000000000000090000000000000000000000
808+000000006e6574207469746c650000001c00b90101005000f303000000000000
809+000000000000000000000000000000000108ba0103000000ef03000000000000
810+090000000000000000000000000000006e6574207469746c650000001c00ba01
811+01005000f303000000000000000000000000000000000000000000000108bb01
812+03000000ef03000000000000090000000000000000000000000000006e657420
813+7469746c650000001c00bb0101005000f3030000000000000000000000000000
814+00000000000000000108bc0103000000ef030000000000000900000000000000
815+00000000000000006e6574207469746c650000001c00bc0101005000f3030000
816+00000000000000000000000000000000000000000108bd0103000000ef030000
817+00000000090000000000000000000000000000006e6574207469746c65000000
818+1c00bd0101005000f30300000000000000000000000000000000000000000000
819+0108be0103000000ef0300000000000009000000000000000000000000000000
820+6e6574207469746c650000001c00be0101005000f30300000000000000000000
821+0000000000000000000000000108bf0103000000ef0300000000000009000000
822+0000000000000000000000006e6574207469746c650000001c00bf0101005000
823+f303000000000000000000000000000000000000000000000108c00103000000
824+ef03000000000000090000000000000000000000000000006e6574207469746c
825+650000001c00c00101005000f303000000000000000000000000000000000000
826+000000000108c10103000000ef03000000000000090000000000000000000000
827+000000006e6574207469746c650000001c00c10101005000f303000000000000
828+000000000000000000000000000000000108c20103000000ef03000000000000
829+090000000000000000000000000000006e6574207469746c650000001c00c201
830+01005000f303000000000000000000000000000000000000000000000108c301
831+03000000ef03000000000000090000000000000000000000000000006e657420
832+7469746c650000001c00c30101005000f3030000000000000000000000000000
833+00000000000000000108c40103000000ef030000000000000900000000000000
834+00000000000000006e6574207469746c650000001c00c40101005000f3030000
835+00000000000000000000000000000000000000000108c50103000000ef030000
836+00000000090000000000000000000000000000006e6574207469746c65000000
837+1c00c50101005000f30300000000000000000000000000000000000000000000
838+0108c60103000000ef0300000000000009000000000000000000000000000000
839+6e6574207469746c650000001c00c60101005000f30300000000000000000000
840+0000000000000000000000000108c70103000000ef0300000000000009000000
841+0000000000000000000000006e6574207469746c650000001c00c70101005000
842+f303000000000000000000000000000000000000000000000108c80103000000
843+ef03000000000000090000000000000000000000000000006e6574207469746c
844+650000001c00c80101005000f303000000000000000000000000000000000000
845+000000000108c90103000000ef03000000000000090000000000000000000000
846+000000006e6574207469746c650000001c00c90101005000f303000000000000
847+000000000000000000000000000000000108ca0103000000ef03000000000000
848+090000000000000000000000000000006e6574207469746c650000001c00ca01
849+01005000f303000000000000000000000000000000000000000000000108cb01
850+03000000ef03000000000000090000000000000000000000000000006e657420
851+7469746c650000001c00cb0101005000f3030000000000000000000000000000
852+00000000000000000108cc0103000000ef030000000000000900000000000000
853+00000000000000006e6574207469746c650000001c00cc0101005000f3030000
854+00000000000000000000000000000000000000000108cd0103000000ef030000
855+00000000090000000000000000000000000000006e6574207469746c65000000
856+1c00cd0101005000f30300000000000000000000000000000000000000000000
857+0108ce0103000000ef0300000000000009000000000000000000000000000000
858+6e6574207469746c650000001c00ce0101005000f30300000000000000000000
859+0000000000000000000000000108cf0103000000ef0300000000000009000000
860+0000000000000000000000006e6574207469746c650000001c00cf0101005000
861+f303000000000000000000000000000000000000000000000108d00103000000
862+ef03000000000000090000000000000000000000000000006e6574207469746c
863+650000001c00d00101005000f303000000000000000000000000000000000000
864+000000000108d10103000000ef03000000000000090000000000000000000000
865+000000006e6574207469746c650000001c00d10101005000f303000000000000
866+000000000000000000000000000000000108d20103000000ef03000000000000
867+090000000000000000000000000000006e6574207469746c650000001c00d201
868+01005000f303000000000000000000000000000000000000000000000108d301
869+03000000ef03000000000000090000000000000000000000000000006e657420
870+7469746c650000001c00d30101005000f3030000000000000000000000000000
871+00000000000000000108d40103000000ef030000000000000900000000000000
872+00000000000000006e6574207469746c650000001c00d40101005000f3030000
873+00000000000000000000000000000000000000000108d50103000000ef030000
874+00000000090000000000000000000000000000006e6574207469746c65000000
875+1c00d50101005000f30300000000000000000000000000000000000000000000
876+0108d60103000000ef0300000000000009000000000000000000000000000000
877+6e6574207469746c650000001c00d60101005000f30300000000000000000000
878+0000000000000000000000000108d70103000000ef0300000000000009000000
879+0000000000000000000000006e6574207469746c650000001c00d70101005000
880+f303000000000000000000000000000000000000000000000108d80103000000
881+ef03000000000000090000000000000000000000000000006e6574207469746c
882+650000001c00d80101005000f303000000000000000000000000000000000000
883+000000000108d90103000000ef03000000000000090000000000000000000000
884+000000006e6574207469746c650000001c00d90101005000f303000000000000
885+000000000000000000000000000000000108da0103000000ef03000000000000
886+090000000000000000000000000000006e6574207469746c650000001c00da01
887+01005000f303000000000000000000000000000000000000000000000108db01
888+03000000ef03000000000000090000000000000000000000000000006e657420
889+7469746c650000001c00db0101005000f3030000000000000000000000000000
890+00000000000000000108dc0103000000ef030000000000000900000000000000
891+00000000000000006e6574207469746c650000001c00dc0101005000f3030000
892+00000000000000000000000000000000000000000108dd0103000000ef030000
893+00000000090000000000000000000000000000006e6574207469746c65000000
894+1c00dd0101005000f30300000000000000000000000000000000000000000000
895+0108de0103000000ef0300000000000009000000000000000000000000000000
896+6e6574207469746c650000001c00de0101005000f30300000000000000000000
897+0000000000000000000000000108df0103000000ef0300000000000009000000
898+0000000000000000000000006e6574207469746c650000001c00df0101005000
899+f303000000000000000000000000000000000000000000000108e00103000000
900+ef03000000000000090000000000000000000000000000006e6574207469746c
901+650000001c00e00101005000f303000000000000000000000000000000000000
902+000000000108e10103000000ef03000000000000090000000000000000000000
903+000000006e6574207469746c650000001c00e10101005000f303000000000000
904+000000000000000000000000000000000108e20103000000ef03000000000000
905+090000000000000000000000000000006e6574207469746c650000001c00e201
906+01005000f303000000000000000000000000000000000000000000000108e301
907+03000000ef03000000000000090000000000000000000000000000006e657420
908+7469746c650000001c00e30101005000f3030000000000000000000000000000
909+00000000000000000108e40103000000ef030000000000000900000000000000
910+00000000000000006e6574207469746c650000001c00e40101005000f3030000
911+00000000000000000000000000000000000000000108e50103000000ef030000
912+00000000090000000000000000000000000000006e6574207469746c65000000
913+1c00e50101005000f30300000000000000000000000000000000000000000000
914+0108e60103000000ef0300000000000009000000000000000000000000000000
915+6e6574207469746c650000001c00e60101005000f30300000000000000000000
916+0000000000000000000000000108e70103000000ef0300000000000009000000
917+0000000000000000000000006e6574207469746c650000001c00e70101005000
918+f303000000000000000000000000000000000000000000000108e80103000000
919+ef03000000000000090000000000000000000000000000006e6574207469746c
920+650000001c00e80101005000f303000000000000000000000000000000000000
921+000000000108e90103000000ef03000000000000090000000000000000000000
922+000000006e6574207469746c650000001c00e90101005000f303000000000000
923+000000000000000000000000000000000108ea0103000000ef03000000000000
924+090000000000000000000000000000006e6574207469746c650000001c00ea01
925+01005000f303000000000000000000000000000000000000000000000108eb01
926+03000000ef03000000000000090000000000000000000000000000006e657420
927+7469746c650000001c00eb0101005000f3030000000000000000000000000000
928+00000000000000000108ec0103000000ef030000000000000900000000000000
929+00000000000000006e6574207469746c650000001c00ec0101005000f3030000
930+00000000000000000000000000000000000000000108ed0103000000ef030000
931+00000000090000000000000000000000000000006e6574207469746c65000000
932+1c00ed0101005000f30300000000000000000000000000000000000000000000
933+0108ee0103000000ef0300000000000009000000000000000000000000000000
934+6e6574207469746c650000001c00ee0101005000f30300000000000000000000
935+0000000000000000000000000108ef0103000000ef0300000000000009000000
936+0000000000000000000000006e6574207469746c650000001c00ef0101005000
937+f303000000000000000000000000000000000000000000000108f00103000000
938+ef03000000000000090000000000000000000000000000006e6574207469746c
939+650000001c00f00101005000f303000000000000000000000000000000000000
940+000000000108f10103000000ef03000000000000090000000000000000000000
941+000000006e6574207469746c650000001c00f10101005000f303000000000000
942+000000000000000000000000000000000108f20103000000ef03000000000000
943+090000000000000000000000000000006e6574207469746c650000001c00f201
944+01005000f303000000000000000000000000000000000000000000000108f301
945+03000000ef03000000000000090000000000000000000000000000006e657420
946+7469746c650000001c00f30101005000f3030000000000000000000000000000
947+00000000000000000108f40103000000ef030000000000000900000000000000
948+00000000000000006e6574207469746c650000001c00f40101005000f3030000
949+00000000000000000000000000000000000000000108f50103000000ef030000
950+00000000090000000000000000000000000000006e6574207469746c65000000
A internal/adapters/wayland/xwm/testdata/streams/steady.hex +2150 -0 large diff (2150 lines changed): collapsed
A internal/adapters/wayland/xwm/xpeer_test.go +594 -0
@@ -0,0 +1,594 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "io"
6+ "os"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "golang.org/x/sys/unix"
13+)
14+
15+// Opcodes the peer understands.
16+const (
17+ opCreateWindow = 1
18+ opChangeWindowAttributes = 2
19+ opGetWindowAttributes = 3
20+ opMapWindow = 8
21+ opDeleteProperty = 19
22+ opConfigureWindow = 12
23+ opGetGeometry = 14
24+ opQueryTree = 15
25+ opInternAtom = 16
26+ opChangeProperty = 18
27+ opGetProperty = 20
28+ opSetSelectionOwner = 22
29+ opSendEvent = 25
30+ opSetInputFocus = 42
31+ opGetInputFocus = 43
32+ opQueryExtension = 98
33+ opKillClient = 113
34+)
35+
36+const (
37+ peerRoot = 0x100
38+ peerXIDBase = 0x200000
39+ peerXIDMask = 0x1fffff
40+ peerMaxWords = 0x100000
41+)
42+
43+type xpeerExt struct{ major, firstEvent, firstError uint8 }
44+
45+// xpeerReq is one request the peer received.
46+type xpeerReq struct {
47+ seq uint16
48+ major uint8
49+ minor uint8
50+ raw []byte // whole request, header included
51+}
52+
53+func (r xpeerReq) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
54+
55+type xpeerProp struct {
56+ typ uint32
57+ format uint8
58+ data []byte
59+}
60+
61+type xpeerGeom struct {
62+ x, y int16
63+ w, h, border uint16
64+}
65+
66+// xpeer is a scripted X server speaking the real wire protocol over a
67+// socketpair: setup, extension queries, atoms, properties, geometry, window
68+// tree. It records every request. It is a protocol peer, not a test double of
69+// a project interface.
70+type xpeer struct {
71+ t *testing.T
72+ f *os.File
73+
74+ mu sync.Mutex
75+ seq uint16
76+ reqs []xpeerReq
77+ exts map[string]xpeerExt
78+ atoms map[string]uint32
79+ nextAtom uint32
80+ props map[uint32]map[uint32]xpeerProp
81+ geoms map[uint32]xpeerGeom
82+ attrs map[uint32][2]byte // window -> {overrideRedirect, mapState}
83+ children []uint32
84+ denyRoot bool // answer the root event-mask request with BadAccess
85+ pids map[uint32]uint32 // XRes QueryClientIds answers, by window
86+ bad map[uint32]bool // windows whose GetProperty/GetGeometry fail with BadWindow
87+ hold bool // keep replies until release
88+ held [][]byte
89+ tap []byte // everything written to the client, when recording
90+ record bool
91+
92+ gate atomic.Pointer[chan struct{}] // closed = reading allowed
93+ sbuf [4096]byte
94+ sink atomic.Bool // discard requests without recording or allocating
95+ done chan struct{}
96+}
97+
98+func fullPeerExts() map[string]xpeerExt {
99+ return map[string]xpeerExt{
100+ "BIG-REQUESTS": {133, 0, 0},
101+ "XC-MISC": {136, 0, 0},
102+ "Composite": {142, 0, 0},
103+ "XFIXES": {138, 87, 140},
104+ "X-Resource": {144, 0, 0},
105+ }
106+}
107+
108+// startXPeer returns the client end of a socketpair, to give to Boot, and the
109+// peer serving the other end.
110+func startXPeer(t *testing.T, exts map[string]xpeerExt) (int, *xpeer) {
111+ t.Helper()
112+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ p := &xpeer{
117+ t: t, f: os.NewFile(uintptr(fds[1]), "xpeer"),
118+ exts: exts, atoms: map[string]uint32{}, nextAtom: 1000,
119+ props: map[uint32]map[uint32]xpeerProp{}, geoms: map[uint32]xpeerGeom{}, attrs: map[uint32][2]byte{},
120+ pids: map[uint32]uint32{}, bad: map[uint32]bool{},
121+ done: make(chan struct{}),
122+ }
123+ open := make(chan struct{})
124+ close(open)
125+ p.gate.Store(&open)
126+ t.Cleanup(func() {
127+ p.resume() // a paused peer would never see the close
128+ p.f.Close()
129+ <-p.done
130+ })
131+ go p.serve()
132+ return fds[0], p
133+}
134+
135+func (p *xpeer) setupReply() []byte {
136+ vendor := []byte("xpeer\x00\x00\x00")
137+ b := make([]byte, 40+len(vendor)+40)
138+ b[0], b[2] = 1, 11
139+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
140+ binary.LittleEndian.PutUint32(b[12:], peerXIDBase)
141+ binary.LittleEndian.PutUint32(b[16:], peerXIDMask)
142+ binary.LittleEndian.PutUint16(b[24:], 5)
143+ b[28] = 1
144+ copy(b[40:], vendor)
145+ scr := b[40+len(vendor):]
146+ binary.LittleEndian.PutUint32(scr, peerRoot)
147+ binary.LittleEndian.PutUint32(scr[32:], 0x22) // root visual
148+ scr[38] = 24 // root depth
149+ return b
150+}
151+
152+func (p *xpeer) serve() {
153+ defer close(p.done)
154+ var hdr [12]byte
155+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
156+ return
157+ }
158+ // Auth name and data are empty (NewConn sends none).
159+ if _, err := p.f.Write(p.setupReply()); err != nil {
160+ return
161+ }
162+ for {
163+ <-*p.gate.Load()
164+ if p.sink.Load() {
165+ if !p.discard() {
166+ return
167+ }
168+ continue
169+ }
170+ var h [4]byte
171+ if _, err := io.ReadFull(p.f, h[:]); err != nil {
172+ return
173+ }
174+ raw := h[:]
175+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
176+ if n == 0 { // BIG-REQUESTS header
177+ var l [4]byte
178+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
179+ return
180+ }
181+ raw = append(raw, l[:]...)
182+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
183+ }
184+ rest := make([]byte, n-len(raw))
185+ if _, err := io.ReadFull(p.f, rest); err != nil {
186+ return
187+ }
188+ raw = append(append([]byte(nil), raw...), rest...)
189+ p.mu.Lock()
190+ p.seq++
191+ r := xpeerReq{p.seq, h[0], h[1], raw}
192+ p.reqs = append(p.reqs, r)
193+ p.answer(r, raw[len(raw)-len(rest):])
194+ p.mu.Unlock()
195+ }
196+}
197+
198+// discard reads and drops one request without allocating. It reports false
199+// when the connection ended.
200+func (p *xpeer) discard() bool {
201+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
202+ return false
203+ }
204+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
205+ left := n - 4
206+ if n == 0 {
207+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
208+ return false
209+ }
210+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
211+ }
212+ for left > 0 {
213+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
214+ if err != nil {
215+ return false
216+ }
217+ left -= m
218+ }
219+ return true
220+}
221+
222+// write sends bytes to the client. The caller holds p.mu.
223+func (p *xpeer) write(b []byte) {
224+ if p.record {
225+ p.tap = append(p.tap, b...)
226+ }
227+ _, _ = p.f.Write(b)
228+}
229+
230+func (p *xpeer) reply(data uint8, body []byte) {
231+ words := (max(len(body), 24) - 24 + 3) / 4
232+ b := make([]byte, 32+4*words)
233+ b[0], b[1] = 1, data
234+ binary.LittleEndian.PutUint16(b[2:], p.seq)
235+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
236+ copy(b[8:], body)
237+ if p.hold {
238+ p.held = append(p.held, b)
239+ return
240+ }
241+ p.write(b)
242+}
243+
244+// release sends the held replies, in request order as a real server does.
245+func (p *xpeer) release() {
246+ p.mu.Lock()
247+ defer p.mu.Unlock()
248+ p.hold = false
249+ for _, b := range p.held {
250+ p.write(b)
251+ }
252+ p.held = nil
253+}
254+
255+// holdReplies keeps the replies of the following requests back.
256+func (p *xpeer) holdReplies() {
257+ p.mu.Lock()
258+ p.hold = true
259+ p.mu.Unlock()
260+}
261+
262+func (p *xpeer) errorPacket(code, major uint8, bad uint32) {
263+ b := make([]byte, 32)
264+ b[1] = code
265+ binary.LittleEndian.PutUint16(b[2:], p.seq)
266+ binary.LittleEndian.PutUint32(b[4:], bad)
267+ b[10] = major
268+ p.write(b)
269+}
270+
271+func (p *xpeer) extName(major uint8) string {
272+ for name, e := range p.exts {
273+ if e.major == major {
274+ return name
275+ }
276+ }
277+ return ""
278+}
279+
280+func le32(v ...uint32) []byte {
281+ b := make([]byte, 0, 4*len(v))
282+ for _, x := range v {
283+ b = binary.LittleEndian.AppendUint32(b, x)
284+ }
285+ return b
286+}
287+
288+// answer runs with p.mu held.
289+func (p *xpeer) answer(r xpeerReq, body []byte) {
290+ switch {
291+ case r.major == opQueryExtension:
292+ e, ok := p.exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
293+ var present uint8
294+ if ok {
295+ present = 1
296+ }
297+ p.reply(0, []byte{present, e.major, e.firstEvent, e.firstError})
298+ case r.major == opInternAtom:
299+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
300+ a, ok := p.atoms[name]
301+ if !ok {
302+ p.nextAtom++
303+ a = p.nextAtom
304+ p.atoms[name] = a
305+ }
306+ p.reply(0, le32(a))
307+ case r.major == opGetInputFocus:
308+ p.reply(1, le32(0))
309+ case r.major == opQueryTree:
310+ b := make([]byte, 24+4*len(p.children))
311+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
312+ binary.LittleEndian.PutUint16(b[8:], uint16(len(p.children)))
313+ for i, c := range p.children {
314+ binary.LittleEndian.PutUint32(b[24+4*i:], c)
315+ }
316+ p.reply(0, b)
317+ case r.major == opGetWindowAttributes:
318+ a := p.attrs[binary.LittleEndian.Uint32(body)]
319+ b := make([]byte, 36) // reply length 3
320+ b[18] = a[1] // map_state is byte 26 of the reply
321+ b[19] = a[0] // override_redirect, byte 27
322+ p.reply(0, b)
323+ case r.major == opGetGeometry && p.bad[binary.LittleEndian.Uint32(body)]:
324+ p.errorPacket(9, r.major, binary.LittleEndian.Uint32(body)) // BadDrawable
325+ case r.major == opGetProperty && p.bad[binary.LittleEndian.Uint32(body)]:
326+ p.errorPacket(3, r.major, binary.LittleEndian.Uint32(body)) // BadWindow
327+ case r.major == opConfigureWindow:
328+ g := p.geoms[binary.LittleEndian.Uint32(body)]
329+ mask, vals := binary.LittleEndian.Uint16(body[4:]), body[8:]
330+ for bit, dst := 0, []any{&g.x, &g.y, &g.w, &g.h, &g.border}; bit < 5; bit++ {
331+ if mask&(1<<bit) == 0 {
332+ continue
333+ }
334+ v := uint16(binary.LittleEndian.Uint32(vals))
335+ vals = vals[4:]
336+ switch d := dst[bit].(type) {
337+ case *int16:
338+ *d = int16(v)
339+ case *uint16:
340+ *d = v
341+ }
342+ }
343+ p.geoms[binary.LittleEndian.Uint32(body)] = g
344+ case r.major == opGetGeometry:
345+ g := p.geoms[binary.LittleEndian.Uint32(body)]
346+ b := make([]byte, 24)
347+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
348+ binary.LittleEndian.PutUint16(b[4:], uint16(g.x))
349+ binary.LittleEndian.PutUint16(b[6:], uint16(g.y))
350+ binary.LittleEndian.PutUint16(b[8:], g.w)
351+ binary.LittleEndian.PutUint16(b[10:], g.h)
352+ binary.LittleEndian.PutUint16(b[12:], g.border)
353+ p.reply(24, b)
354+ case r.major == opChangeProperty:
355+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
356+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
357+ if p.props[win] == nil {
358+ p.props[win] = map[uint32]xpeerProp{}
359+ }
360+ p.props[win][prop] = xpeerProp{
361+ typ: binary.LittleEndian.Uint32(body[8:]), format: format,
362+ data: append([]byte(nil), body[20:20+n*int(format)/8]...),
363+ }
364+ case r.major == opDeleteProperty:
365+ delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))
366+ case r.major == opGetProperty:
367+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
368+ v, ok := p.props[win][prop]
369+ b := make([]byte, 24+(len(v.data)+3)&^3)
370+ if ok && v.format != 0 {
371+ binary.LittleEndian.PutUint32(b[0:], v.typ)
372+ binary.LittleEndian.PutUint32(b[8:], uint32(len(v.data)/(int(v.format)/8)))
373+ copy(b[24:], v.data)
374+ }
375+ p.reply(v.format, b)
376+ case r.major == opChangeWindowAttributes:
377+ const cwEventMask = 1 << 11
378+ if p.denyRoot && binary.LittleEndian.Uint32(body) == peerRoot && binary.LittleEndian.Uint32(body[4:])&cwEventMask != 0 {
379+ p.errorPacket(10, opChangeWindowAttributes, peerRoot) // BadAccess
380+ }
381+ case r.major >= 128:
382+ switch p.extName(r.major) {
383+ case "BIG-REQUESTS":
384+ p.reply(0, le32(peerMaxWords))
385+ case "XC-MISC":
386+ if r.minor == 1 {
387+ p.reply(0, le32(0, 1))
388+ }
389+ case "Composite", "XFIXES":
390+ if r.minor == 0 {
391+ p.reply(0, le32(5, 4))
392+ }
393+ case "X-Resource":
394+ switch r.minor {
395+ case 0:
396+ p.reply(0, []byte{1, 0, 2, 0})
397+ case 4: // QueryClientIds: one spec, answered when a pid is scripted
398+ pid, ok := p.pids[r.u32(8)]
399+ if !ok {
400+ p.reply(0, le32(0))
401+ break
402+ }
403+ b := make([]byte, 40)
404+ binary.LittleEndian.PutUint32(b[0:], 1)
405+ binary.LittleEndian.PutUint32(b[24:], r.u32(8))
406+ binary.LittleEndian.PutUint32(b[28:], 2) // LocalClientPID
407+ binary.LittleEndian.PutUint32(b[32:], 4)
408+ binary.LittleEndian.PutUint32(b[36:], pid)
409+ p.reply(0, b)
410+ }
411+ }
412+ }
413+}
414+
415+// requests returns a snapshot of every request received after setup.
416+func (p *xpeer) requests() []xpeerReq {
417+ p.mu.Lock()
418+ defer p.mu.Unlock()
419+ return append([]xpeerReq(nil), p.reqs...)
420+}
421+
422+// waitReq waits for a request satisfying match among those after the first
423+// skip requests, and returns it with the new count.
424+func (p *xpeer) waitReq(skip int, match func(xpeerReq) bool) (xpeerReq, int) {
425+ p.t.Helper()
426+ deadline := time.Now().Add(5 * time.Second)
427+ for time.Now().Before(deadline) {
428+ reqs := p.requests()
429+ for i := skip; i < len(reqs); i++ {
430+ if match(reqs[i]) {
431+ return reqs[i], i + 1
432+ }
433+ }
434+ time.Sleep(time.Millisecond)
435+ }
436+ p.t.Fatal("xpeer: expected request never arrived")
437+ return xpeerReq{}, 0
438+}
439+
440+func (p *xpeer) atom(name string) uint32 {
441+ p.mu.Lock()
442+ defer p.mu.Unlock()
443+ return p.atoms[name]
444+}
445+
446+func (p *xpeer) prop(win uint32, atom string) (xpeerProp, bool) {
447+ p.mu.Lock()
448+ defer p.mu.Unlock()
449+ v, ok := p.props[win][p.atoms[atom]]
450+ return v, ok
451+}
452+
453+// event sends a 32-byte core event stamped with the current sequence number.
454+func (p *xpeer) event(code uint8, fill func(b *[32]byte)) {
455+ p.mu.Lock()
456+ defer p.mu.Unlock()
457+ var b [32]byte
458+ b[0] = code
459+ binary.LittleEndian.PutUint16(b[2:], p.seq)
460+ if fill != nil {
461+ fill(&b)
462+ }
463+ p.write(b[:])
464+}
465+
466+func (p *xpeer) createNotify(win uint32, override bool) {
467+ p.event(16, func(b *[32]byte) {
468+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
469+ binary.LittleEndian.PutUint32(b[8:], win)
470+ binary.LittleEndian.PutUint16(b[16:], 100)
471+ binary.LittleEndian.PutUint16(b[18:], 50)
472+ if override {
473+ b[22] = 1
474+ }
475+ })
476+}
477+
478+func (p *xpeer) destroyNotify(win uint32) {
479+ p.event(17, func(b *[32]byte) {
480+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
481+ binary.LittleEndian.PutUint32(b[8:], win)
482+ })
483+}
484+
485+// pause makes the peer stop reading after the request it is serving.
486+func (p *xpeer) pause() {
487+ g := make(chan struct{})
488+ p.gate.Store(&g)
489+}
490+
491+// resume lets the peer read again.
492+func (p *xpeer) resume() {
493+ g := *p.gate.Load()
494+ open := make(chan struct{})
495+ close(open)
496+ p.gate.Store(&open)
497+ select {
498+ case <-g:
499+ default:
500+ close(g)
501+ }
502+}
503+
504+// set mutates the peer's script under its lock.
505+func (p *xpeer) set(fn func(p *xpeer)) {
506+ p.mu.Lock()
507+ defer p.mu.Unlock()
508+ fn(p)
509+}
510+
511+// hangup closes the server end; the client reads EOF.
512+func (p *xpeer) hangup() {
513+ if rc, err := p.f.SyscallConn(); err == nil {
514+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
515+ }
516+}
517+
518+// setProp scripts a window property for GetProperty.
519+func (p *xpeer) setProp(win, atom, typ uint32, format uint8, data []byte) {
520+ p.mu.Lock()
521+ defer p.mu.Unlock()
522+ if p.props[win] == nil {
523+ p.props[win] = map[uint32]xpeerProp{}
524+ }
525+ p.props[win][atom] = xpeerProp{typ: typ, format: format, data: data}
526+}
527+
528+func (p *xpeer) mapRequest(win uint32) {
529+ p.event(20, func(b *[32]byte) {
530+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
531+ binary.LittleEndian.PutUint32(b[8:], win)
532+ })
533+}
534+
535+func (p *xpeer) mapNotify(win uint32) {
536+ p.event(19, func(b *[32]byte) {
537+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
538+ binary.LittleEndian.PutUint32(b[8:], win)
539+ })
540+}
541+
542+func (p *xpeer) unmapNotify(win uint32, synthetic bool) {
543+ p.event(18, func(b *[32]byte) {
544+ if synthetic {
545+ b[0] |= 0x80
546+ }
547+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
548+ binary.LittleEndian.PutUint32(b[8:], win)
549+ })
550+}
551+
552+func (p *xpeer) propertyNotify(win, atom uint32, deleted bool) {
553+ p.event(28, func(b *[32]byte) {
554+ binary.LittleEndian.PutUint32(b[4:], win)
555+ binary.LittleEndian.PutUint32(b[8:], atom)
556+ if deleted {
557+ b[16] = 1
558+ }
559+ })
560+}
561+
562+func (p *xpeer) reparentNotify(win, parent uint32) {
563+ p.event(21, func(b *[32]byte) {
564+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
565+ binary.LittleEndian.PutUint32(b[8:], win)
566+ binary.LittleEndian.PutUint32(b[12:], parent)
567+ })
568+}
569+
570+func (p *xpeer) clientMessage(win, typ uint32, data ...uint32) {
571+ p.event(33, func(b *[32]byte) {
572+ b[1] = 32
573+ binary.LittleEndian.PutUint32(b[4:], win)
574+ binary.LittleEndian.PutUint32(b[8:], typ)
575+ for i, d := range data {
576+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
577+ }
578+ })
579+}
580+
581+func (p *xpeer) focusIn(win uint32) {
582+ p.event(9, func(b *[32]byte) { binary.LittleEndian.PutUint32(b[4:], win) })
583+}
584+
585+func (p *xpeer) configureNotify(win uint32) {
586+ p.event(22, func(b *[32]byte) {
587+ binary.LittleEndian.PutUint32(b[4:], win)
588+ binary.LittleEndian.PutUint32(b[8:], win)
589+ binary.LittleEndian.PutUint16(b[20:], 10)
590+ binary.LittleEndian.PutUint16(b[22:], 20)
591+ binary.LittleEndian.PutUint16(b[24:], 300)
592+ binary.LittleEndian.PutUint16(b[26:], 200)
593+ })
594+}
A internal/adapters/wayland/xwm/xwm.go +438 -0
@@ -0,0 +1,438 @@
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+
18+ "bnema.dev/xturbo"
19+ "bnema.dev/xturbo/composite"
20+ "bnema.dev/xturbo/res"
21+ "bnema.dev/xturbo/xfixes"
22+ "bnema.dev/xturbo/xproto"
23+ "github.com/bnema/purego-libwayland/server"
24+ "github.com/bnema/zerowrap"
25+ "golang.org/x/sys/unix"
26+)
27+
28+var (
29+ // ErrWMExists is returned by Boot when another client already redirects
30+ // the root window's children.
31+ ErrWMExists = errors.New("xwm: another window manager owns the root window")
32+ // ErrNoComposite is returned by Boot when the X server lacks Composite.
33+ ErrNoComposite = errors.New("xwm: X server has no Composite extension")
34+)
35+
36+// Stats are connection and event counters, for diagnostics.
37+type Stats struct {
38+ Events, Replies, Errors, Unattributed, Stale, RingFull, LegacyID, UnmanagedSeen uint64
39+}
40+
41+// Version request ids, carried in the first Expect argument of kindVersion.
42+const (
43+ versionComposite uint32 = iota + 1
44+ versionXFixes
45+ versionRes
46+)
47+
48+const unpairedCap = 64
49+
50+// XWM manages the windows of one Xwayland. Display goroutine only, after Boot.
51+type XWM struct {
52+ conn *xturbo.Conn
53+ log zerowrap.Logger
54+ root uint32
55+ wmWin uint32
56+ atoms [atomCount]uint32
57+ tab *table
58+ h xturbo.Handler
59+ stats Stats
60+
61+ // attachment
62+ host host
63+ src fdSource
64+ post func(func())
65+ flushFn func()
66+
67+ posted, writable, closed bool
68+ bootErr error
69+ warnedRing, warnedFull bool
70+ warnedUnpaired bool
71+
72+ scratch []byte // encode buffer for 32-bit property values, 4 bytes per slot
73+
74+ // Shared by the map, configure and focus flows.
75+ dirty []Slot // slots with a pending configure, capacity == table size
76+ clients []uint32 // _NET_CLIENT_LIST, map order
77+ stacking []uint32 // _NET_CLIENT_LIST_STACKING, bottom to top
78+ active Slot // slot named by _NET_ACTIVE_WINDOW, 0 = none
79+ refocus bool // a FocusIn was denied; focus is re-issued in flush
80+ listsDirty uint8 // client lists to rewrite in flush
81+ unpaired [unpairedCap]struct{ serial, surface uint64 }
82+ unpairedN int
83+}
84+
85+func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
86+ x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots)}
87+ x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
88+ x.fit()
89+ return x
90+}
91+
92+// fit sizes the per-slot buffers to the table; called after every insert.
93+func (x *XWM) fit() {
94+ n := len(x.tab.recs)
95+ if cap(x.dirty) < n {
96+ x.dirty = append(make([]Slot, 0, n), x.dirty...)
97+ x.clients = append(make([]uint32, 0, n), x.clients...)
98+ x.stacking = append(make([]uint32, 0, n), x.stacking...)
99+ x.scratch = make([]byte, 0, 4*n)
100+ }
101+}
102+
103+// insert adds a record for xid; 0 means the table is full.
104+func (x *XWM) insert(xid uint32) Slot {
105+ s := x.tab.insert(xid)
106+ if s == 0 {
107+ if !x.warnedFull {
108+ x.warnedFull = true
109+ x.log.Warn().Int("windows", x.tab.n).Msg("X11 window table full, window ignored")
110+ }
111+ return 0
112+ }
113+ x.fit()
114+ return s
115+}
116+
117+// expect registers seq, tolerating a full ring: the reply then arrives
118+// unattributed and is dropped.
119+func (x *XWM) expect(seq xturbo.Seq, kind uint16, a0, a1 uint32) {
120+ if seq == 0 {
121+ return
122+ }
123+ if err := x.conn.Expect(seq, kind, a0, a1, 0, 0); err != nil {
124+ x.stats.RingFull++
125+ if !x.warnedRing {
126+ x.warnedRing = true
127+ x.log.Warn().Err(err).Msg("X11 request not tracked")
128+ }
129+ }
130+}
131+
132+// setProp32 queues ChangeProperty (replace, format 32) for vals.
133+func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
134+ n := 4 * len(vals)
135+ if cap(x.scratch) < n {
136+ x.scratch = make([]byte, 0, n)
137+ }
138+ p := x.scratch[:n]
139+ for i, v := range vals {
140+ binary.LittleEndian.PutUint32(p[4*i:], v)
141+ }
142+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(win),
143+ xproto.Atom(x.atoms[prop]), typ, 32, uint32(len(vals)), p)
144+}
145+
146+// Boot connects to the X server on wmFD and becomes its window manager. It
147+// blocks (about 25 s at worst) and must not run on the display goroutine: the
148+// handshake needs Xwayland, which needs the display goroutine to serve its
149+// Wayland requests. It takes ownership of wmFD and closes it on failure.
150+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
151+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
152+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
153+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
154+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
155+ // can never reach a descriptor number reused after Boot closed wmFD.
156+ var watch *os.File
157+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
158+ watch = os.NewFile(uintptr(dup), "xwm abort")
159+ }
160+ stopWatch := func() {}
161+ if watch != nil {
162+ quit, joined := make(chan struct{}), make(chan struct{})
163+ go func() {
164+ defer close(joined)
165+ select {
166+ case <-ctx.Done():
167+ if rc, err := watch.SyscallConn(); err == nil {
168+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
169+ }
170+ case <-quit:
171+ }
172+ _ = watch.Close()
173+ }()
174+ stopWatch = func() { close(quit); <-joined }
175+ }
176+ x, err := boot(wmFD, log)
177+ stopWatch()
178+ if err == nil && ctx.Err() != nil {
179+ x.CloseConn()
180+ x, err = nil, ctx.Err()
181+ }
182+ if err != nil && ctx.Err() != nil {
183+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
184+ }
185+ return x, err
186+}
187+
188+func boot(wmFD int, log zerowrap.Logger) (*XWM, error) {
189+ conn, err := xturbo.NewConn(wmFD)
190+ if err != nil {
191+ return nil, fmt.Errorf("xwm: connect: %w", err)
192+ }
193+ x := newXWM(conn, log)
194+ if err := x.boot(); err != nil {
195+ _ = conn.Close()
196+ return nil, err
197+ }
198+ return x, nil
199+}
200+
201+func (x *XWM) boot() error {
202+ conn := x.conn
203+ ext := conn.Ext()
204+ if !ext.Info(xturbo.ExtComposite).Present {
205+ return ErrNoComposite
206+ }
207+ var visual, depth uint32
208+ if it := xproto.NewSetupIter(conn.SetupBytes(), 1); it.Next() {
209+ if roots := it.Value().Roots(); roots.Next() {
210+ visual, depth = uint32(roots.Value().RootVisual()), uint32(roots.Value().RootDepth())
211+ }
212+ }
213+ x.log.Info().Uint32("root", x.root).Uint32("visual", visual).Uint32("depth", depth).
214+ Bool("xfixes", ext.Info(xturbo.ExtXFixes).Present).
215+ Bool("res", ext.Info(xturbo.ExtRes).Present).Msg("X server")
216+
217+ if err := conn.InternAtoms(atomNames[:], false, x.atoms[:]); err != nil {
218+ return fmt.Errorf("xwm: intern atoms: %w", err)
219+ }
220+ xid, err := conn.NewXID()
221+ if err != nil {
222+ return fmt.Errorf("xwm: allocate window id: %w", err)
223+ }
224+ x.wmWin = xid
225+
226+ b := conn.Buf()
227+ x.expect(composite.QueryVersion(b, 0, 4), kindVersion, versionComposite, 0)
228+ if ext.Info(xturbo.ExtXFixes).Present {
229+ x.expect(xfixes.QueryVersion(b, 6, 0), kindVersion, versionXFixes, 0)
230+ }
231+ if ext.Info(xturbo.ExtRes).Present {
232+ x.expect(res.QueryVersion(b, 1, 2), kindVersion, versionRes, 0)
233+ }
234+
235+ root := xproto.Window(x.root)
236+ var rootMask xproto.ChangeWindowAttributesValues
237+ rootMask.SetEventMask(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
238+ x.expect(xproto.ChangeWindowAttributes(b, root, &rootMask), kindRootMask, 0, 0)
239+ // Only one client may select SubstructureRedirect on the root: wait for
240+ // the answer before touching anything another window manager owns.
241+ if err := b.Err(); err != nil {
242+ return fmt.Errorf("xwm: encode root mask: %w", err)
243+ }
244+ if err := conn.Sync(&x.h); err != nil {
245+ return fmt.Errorf("xwm: root mask: %w", err)
246+ }
247+ if x.bootErr != nil {
248+ return x.bootErr
249+ }
250+ composite.RedirectSubwindows(b, root, composite.RedirectManual)
251+
252+ var wv xproto.CreateWindowValues
253+ wv.SetEventMask(0)
254+ xproto.CreateWindow(b, 0, xproto.Window(x.wmWin), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOutput, 0, &wv)
255+ name := []byte("neferwl")
256+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMName]),
257+ xproto.Atom(x.atoms[atomUTF8String]), 8, uint32(len(name)), name)
258+ x.setProp32(x.root, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
259+ x.setProp32(x.wmWin, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
260+ // CurrentTime and no MANAGER announcement: this Xwayland is private to
261+ // neferwl and nobody waits on these selections.
262+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomWMS0]), 0)
263+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMCMS0]), 0)
264+
265+ supported := [...]int{
266+ atomNetWMState, atomNetActiveWindow, atomNetWMStateFocused,
267+ atomNetWMStateModal, atomNetWMStateFullscreen, atomNetClientList,
268+ atomNetClientListStacking, atomNetSupportingWMCheck, atomNetWMName, atomNetWMPID,
269+ atomNetWMWindowType,
270+ }
271+ var list [len(supported)]uint32
272+ for i, a := range supported {
273+ list[i] = x.atoms[a]
274+ }
275+ x.setProp32(x.root, atomNetSupported, xproto.AtomAtom, list[:])
276+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, []uint32{0})
277+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, nil)
278+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, nil)
279+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
280+ if err := b.Err(); err != nil {
281+ return fmt.Errorf("xwm: encode setup requests: %w", err)
282+ }
283+ if err := conn.Sync(&x.h); err != nil {
284+ return fmt.Errorf("xwm: root setup: %w", err)
285+ }
286+ if x.bootErr != nil {
287+ return x.bootErr
288+ }
289+
290+ // Windows that exist already (Xwayland restarted under a live client set).
291+ x.expect(xproto.QueryTree(b, root), kindTree, 0, 0)
292+ if err := conn.Sync(&x.h); err != nil {
293+ return fmt.Errorf("xwm: query tree: %w", err)
294+ }
295+ return x.bootErr
296+}
297+
298+// Attach registers the X socket with the display and hands the windows found
299+// at boot to h. Display goroutine only.
300+func (x *XWM) Attach(d *server.Display, h host) error {
301+ src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
302+ if err != nil {
303+ return fmt.Errorf("xwm: watch X socket: %w", err)
304+ }
305+ x.attach(src, h, d.Post)
306+ return nil
307+}
308+
309+// attach is Attach over its seams: the event source and the display's Post.
310+func (x *XWM) attach(src fdSource, h host, post func(func())) {
311+ x.src, x.host, x.post = src, h, post
312+ x.flushFn = func() {
313+ x.posted = false
314+ x.flush()
315+ }
316+ for i := range x.tab.recs {
317+ r := &x.tab.recs[i]
318+ if r.kind != kindManaged || r.id != 0 {
319+ continue
320+ }
321+ s := Slot(i + 1)
322+ x.adopt(s)
323+ if r.mapping { // MapRequest or viewable before Attach
324+ r.mapping = false
325+ x.beginMap(s)
326+ }
327+ }
328+ x.flush()
329+}
330+
331+// Close detaches from the display, drops every window from the host and closes
332+// the X connection. Idempotent.
333+func (x *XWM) Close() {
334+ if x.closed {
335+ return
336+ }
337+ x.closed = true
338+ s := x.stats
339+ x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
340+ Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).
341+ Uint64("legacy_id", s.LegacyID).Uint64("unmanaged_seen", s.UnmanagedSeen).Msg("X11 window management closed")
342+ if x.src != nil {
343+ x.src.Remove()
344+ x.src = nil
345+ }
346+ if x.host != nil {
347+ for i := range x.tab.recs {
348+ if r := &x.tab.recs[i]; r.kind == kindManaged && r.id != 0 {
349+ r.id = 0
350+ x.host.Dissociate(Slot(i+1), true)
351+ }
352+ }
353+ }
354+ x.unpairedN = 0
355+ _ = x.conn.Close()
356+}
357+
358+// CloseConn closes the connection of an XWM that was never attached.
359+func (x *XWM) CloseConn() {
360+ if !x.closed {
361+ x.closed = true
362+ _ = x.conn.Close()
363+ }
364+}
365+
366+// fail ends window management after an unrecoverable connection error. Xwayland
367+// keeps running; its windows no longer map.
368+func (x *XWM) fail(err error) {
369+ if x.closed {
370+ return
371+ }
372+ if errors.Is(err, io.EOF) || errors.Is(err, xturbo.ErrClosed) {
373+ x.log.Info().Err(err).Msg("X11 window management stopped: Xwayland closed the connection")
374+ } else {
375+ x.log.Warn().Err(err).Msg("X11 window management stopped")
376+ }
377+ x.Close()
378+}
379+
380+// Stats returns the counters; Close logs them.
381+func (x *XWM) Stats() Stats { return x.stats }
382+
383+func (x *XWM) onReadable() {
384+ if x.closed {
385+ return
386+ }
387+ if err := x.conn.Dispatch(&x.h); err != nil {
388+ x.fail(err)
389+ return
390+ }
391+ x.flush()
392+}
393+
394+func (x *XWM) onWritable() { x.flush() }
395+
396+// wake schedules one flush at the end of the current display iteration.
397+func (x *XWM) wake() {
398+ if x.posted || x.closed || x.post == nil {
399+ return
400+ }
401+ x.posted = true
402+ x.post(x.flushFn)
403+}
404+
405+// flush sends what the handlers queued: pending configures, an XID prefetch,
406+// then the socket, arming POLLOUT only while it is full.
407+func (x *XWM) flush() {
408+ if x.closed {
409+ return
410+ }
411+ x.flushConfigures()
412+ if x.conn.XIDsLow() {
413+ _ = x.conn.PrefetchXIDs()
414+ }
415+ err := x.conn.Flush()
416+ switch {
417+ case err == nil:
418+ if x.writable {
419+ x.setWritable(false)
420+ }
421+ case errors.Is(err, xturbo.ErrWouldBlock):
422+ if !x.writable {
423+ x.setWritable(true)
424+ }
425+ default:
426+ x.fail(err)
427+ }
428+}
429+
430+func (x *XWM) setWritable(on bool) {
431+ x.writable = on
432+ if x.src == nil {
433+ return
434+ }
435+ if err := x.src.SetWritable(on); err != nil {
436+ x.fail(err)
437+ }
438+}
A internal/adapters/wayland/xwm/xwm_test.go +381 -0
@@ -0,0 +1,381 @@
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:
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+// The XWM never blocks after Boot: NewXID, Sync and InternAtoms are startup
345+// only, so they may appear in production code only inside boot().
346+func TestNoBlockingCallsOutsideBoot(t *testing.T) {
347+ files, err := os.ReadDir(".")
348+ require.NoError(t, err)
349+ for _, f := range files {
350+ name := f.Name()
351+ if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
352+ continue
353+ }
354+ src, err := os.ReadFile(name)
355+ require.NoError(t, err)
356+ text := string(src)
357+ lo, hi := 0, 0
358+ if name == "xwm.go" {
359+ lo = strings.Index(text, "func (x *XWM) boot() error")
360+ hi = strings.Index(text, "// Attach registers")
361+ require.Greater(t, hi, lo)
362+ }
363+ for _, call := range []string{".NewXID(", ".Sync(", ".InternAtoms("} {
364+ for _, at := range allIndex(text, call) {
365+ require.True(t, name == "xwm.go" && at > lo && at < hi, "%s: %s outside boot", name, call)
366+ }
367+ }
368+ }
369+}
370+
371+func allIndex(s, sub string) []int {
372+ var out []int
373+ for off := 0; ; {
374+ i := strings.Index(s[off:], sub)
375+ if i < 0 {
376+ return out
377+ }
378+ out = append(out, off+i)
379+ off += i + len(sub)
380+ }
381+}
A internal/adapters/xwayland/builtin.go +216 -0
@@ -0,0 +1,216 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+type builtinStarter struct{}
18+
19+func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
20+ // Files own all setup descriptors until Start succeeds and Attach takes
21+ // the Wayland socket. This also unwinds partial setup failures.
22+ var files []*os.File
23+ defer func() {
24+ for _, f := range files {
25+ if f != nil {
26+ f.Close()
27+ }
28+ }
29+ }()
30+ pair := func(name string) (int, error) {
31+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
32+ if err != nil {
33+ return 0, err
34+ }
35+ i := len(files)
36+ files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child"))
37+ return i, nil
38+ }
39+ wl, err := pair("wayland")
40+ if err != nil {
41+ return err
42+ }
43+ wm, err := pair("wm")
44+ if err != nil {
45+ return err
46+ }
47+ var pipe [2]int
48+ if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil {
49+ return err
50+ }
51+ rd := os.NewFile(uintptr(pipe[0]), "displayfd read")
52+ wr := os.NewFile(uintptr(pipe[1]), "displayfd write")
53+ files = append(files, rd, wr)
54+ args := []string{d.Name(), "-rootless"}
55+ for i := range d.sockets {
56+ args = append(args, "-listenfd", strconv.Itoa(3+i))
57+ }
58+ wlChild := 3 + len(d.sockets)
59+ args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2))
60+ cmd := exec.Command(d.opts.Binary, args...)
61+ for _, e := range d.opts.Env {
62+ if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") {
63+ cmd.Env = append(cmd.Env, e)
64+ }
65+ }
66+ cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild))
67+ cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr)
68+ cmd.Stderr = stderr
69+ cmd.WaitDelay = 2 * time.Second
70+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
71+ if err := cmd.Start(); err != nil {
72+ return err
73+ }
74+ files[wl+1].Close()
75+ files[wm+1].Close()
76+ wr.Close()
77+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
78+ // Detach the Wayland descriptor from os.File ownership before handing it
79+ // to the display (including its finalizers).
80+ if d.opts.Attach != nil {
81+ // Dup transfers an independent descriptor; closing the File wrapper below
82+ // cannot race with the display's ownership of the transferred descriptor.
83+ var wlFD int
84+ wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ d.opts.Attach(wlFD)
87+ }
88+ }
89+ files[wl].Close()
90+ done := make(chan error, 1)
91+ procGone := make(chan struct{})
92+ go func() { done <- cmd.Wait(); close(procGone) }()
93+ if err != nil {
94+ stopGroup(cmd, done)
95+ return err
96+ }
97+ exited, readyErr := waitReady(ctx, pipe[0], done)
98+ if readyErr != nil {
99+ err := readyErr
100+ if errors.Is(err, errReadinessTimeout) {
101+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
102+ }
103+ // Join Wait unless readiness already observed process exit.
104+ if !exited {
105+ stopGroup(cmd, done)
106+ }
107+ return err
108+ }
109+ rd.Close()
110+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
111+ if d.opts.AttachWM != nil {
112+ // The WM socket is bootstrapped only now: Xwayland serves it after its
113+ // Wayland round trips, which the display goroutine must be free to answer.
114+ wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0)
115+ if err != nil {
116+ stopGroup(cmd, done)
117+ return err
118+ }
119+ // The context ends with the process or the caller's context, so a
120+ // bootstrap against a dead or silent Xwayland aborts at once.
121+ wmCtx, cancel := context.WithCancel(ctx)
122+ watched := make(chan struct{})
123+ go func() {
124+ defer close(watched)
125+ select {
126+ case <-procGone:
127+ cancel()
128+ case <-wmCtx.Done():
129+ }
130+ }()
131+ stop := d.opts.AttachWM(wmCtx, wmFD)
132+ cancel()
133+ <-watched
134+ if stop != nil {
135+ // Runs before start returns so the restart loop never overlaps two XWMs.
136+ defer stop()
137+ }
138+ }
139+ select {
140+ case err := <-done:
141+ return err
142+ case <-ctx.Done():
143+ stopGroup(cmd, done)
144+ return ctx.Err()
145+ }
146+}
147+
148+var errReadinessTimeout = errors.New("xwayland readiness timed out")
149+
150+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
151+// The result reports whether cmd.Wait was already joined.
152+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
153+ deadline := time.Now().Add(10 * time.Second)
154+ var buf [32]byte
155+ n := 0
156+ for {
157+ select {
158+ case err := <-done:
159+ if err == nil {
160+ err = errors.New("xwayland exited before readiness")
161+ }
162+ return true, err
163+ default:
164+ }
165+ if ctx.Err() != nil {
166+ return false, ctx.Err()
167+ }
168+ if time.Now().After(deadline) {
169+ return false, errReadinessTimeout
170+ }
171+ // A closed pipe also covers process exit, including exec's stderr delay.
172+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
173+ _, err := unix.Poll(p, 50)
174+ if errors.Is(err, unix.EINTR) {
175+ continue
176+ }
177+ if err != nil {
178+ return false, err
179+ }
180+ if p[0].Revents == 0 {
181+ continue
182+ }
183+ if p[0].Revents&unix.POLLIN != 0 {
184+ count, err := unix.Read(fd, buf[n:])
185+ if errors.Is(err, unix.EINTR) {
186+ continue
187+ }
188+ if err != nil {
189+ return false, err
190+ }
191+ n += count
192+ if n > 0 && buf[n-1] == '\n' {
193+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
194+ return false, fmt.Errorf("xwayland displayfd: %w", err)
195+ }
196+ return false, nil
197+ }
198+ if n == len(buf) {
199+ return false, errors.New("invalid Xwayland displayfd response")
200+ }
201+ }
202+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
203+ return false, errors.New("xwayland exited before readiness")
204+ }
205+ }
206+}
207+
208+func stopGroup(cmd *exec.Cmd, done <-chan error) {
209+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
210+ select {
211+ case <-done:
212+ case <-time.After(2 * time.Second):
213+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
214+ <-done
215+ }
216+}
A internal/adapters/xwayland/starter_mock_test.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwayland
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockstarter(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockstarter {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockstarter{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockstarter is an autogenerated mock type for the starter type
37+type mockstarter struct {
38+ mock.Mock
39+}
40+
41+type mockstarter_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockstarter) EXPECT() *mockstarter_Expecter {
46+ return &mockstarter_Expecter{mock: &_m.Mock}
47+}
48+
49+// start provides a mock function for the type mockstarter
50+func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
51+ ret := _mock.Called(ctx, d, stderr)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for start")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok {
59+ r0 = returnFunc(ctx, d, stderr)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
67+type mockstarter_start_Call struct {
68+ *mock.Call
69+}
70+
71+// start is a helper method to define mock.On call
72+// - ctx context.Context
73+// - d *Display
74+// - stderr *logWriter
75+func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call {
76+ return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)}
77+}
78+
79+func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 *Display
86+ if args[1] != nil {
87+ arg1 = args[1].(*Display)
88+ }
89+ var arg2 *logWriter
90+ if args[2] != nil {
91+ arg2 = args[2].(*logWriter)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/adapters/xwayland/xwayland.go +46 -18
@@ -1,6 +1,5 @@
1-// Package xwayland runs X11 clients through xwayland-satellite, started on
2-// demand: neferwl owns the X11 display sockets and starts the satellite
3-// when the first X11 client connects, and again after it exits.
1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2+// sockets and starts the selected backend when the first client connects.
43 package xwayland
54
65 import (
@@ -17,15 +16,26 @@ import (
1716 "syscall"
1817 "time"
1918
19+ "github.com/bnema/neferwl/internal/ports"
2020 "github.com/bnema/zerowrap"
2121 "golang.org/x/sys/unix"
2222 )
2323
2424 // Options configures the X11 display.
2525 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.
2737 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
38+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
2939 Env []string
3040 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
3141 // and /tmp outside tests.
@@ -35,8 +45,9 @@ type Options struct {
3545 Log zerowrap.Logger
3646 }
3747
38-// Display is an X11 display served by xwayland-satellite.
48+// Display is an on-demand X11 display.
3949 type Display struct {
50+ starter starter
4051 opts Options
4152 n int
4253 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) {
8192 _ = os.Remove(lock)
8293 return nil, fmt.Errorf("write X11 lock: %w", err)
8394 }
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+ }
8599 if err := d.listen(); err != nil {
86100 last = err
87101 d.Close()
@@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error {
212226 return err
213227 }
214228 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}
216- err := d.serve(ctx, stderr)
229+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
230+ err := d.starter.start(ctx, d, stderr)
217231 if ctx.Err() != nil {
218232 return nil
219233 }
220234 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")
222236 } 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")
224238 }
225239 // Clients the satellite never accepted would wake us at once: a
226240 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261275 return ctx.Err()
262276 }
263277
264-// serve runs the satellite on the sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
278+// starter runs a backend on the reserved sockets until it exits or ctx ends.
279+type starter interface {
280+ start(ctx context.Context, d *Display, stderr *logWriter) error
281+}
282+
283+type satelliteStarter struct{}
284+
285+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266286 args := []string{d.Name()}
267287 for i := range d.sockets {
268288 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +338,11 @@ func (d *Display) dropPending() {
318338 // logWriter logs the satellite's stderr line by line and keeps the last
319339 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320340 type logWriter struct {
321- mu sync.Mutex
322- log zerowrap.Logger
323- line []byte
324- last []string
341+ backend string
342+ mu sync.Mutex
343+ log zerowrap.Logger
344+ line []byte
345+ last []string
325346 }
326347
327348 // tail is the last lines written.
@@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342363 }
343364 if i > 0 {
344365 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)
346367 if w.last = append(w.last, line); len(w.last) > 5 {
347368 w.last = w.last[1:]
348369 }
@@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354375 }
355376 return len(p), nil
356377 }
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 (
1111 "time"
1212
1313 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
1416 "golang.org/x/sys/unix"
1517 )
1618
@@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270272 }
271273 }
272274 }
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 (
55 "context"
66 "fmt"
77 "io"
8+ "os/exec"
89 "time"
910
1011 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188189 }
189190 }
190191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns
192+// openXwayland reserves an X11 display for the selected backend, or returns
192193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
194- bin := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {
194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
195+ cfg := opts.Config.Xwayland
196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196198 return nil
197199 }
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) {
199206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200207 return nil
201208 }
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})
203210 if err != nil {
204211 log.Warn().Err(err).Msg("X11 display disabled")
205212 return nil
M internal/app/session.go +1 -1
@@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152152 s.statePath = statePath
153153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154154 }
155- if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
155+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil {
156156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157157 }
158158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
A internal/app/testdata/x11probe/main.go +141 -0
@@ -0,0 +1,141 @@
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+package main
7+
8+import (
9+ "encoding/binary"
10+ "flag"
11+ "fmt"
12+ "os"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "golang.org/x/sys/unix"
18+
19+ "bnema.dev/xturbo"
20+ "bnema.dev/xturbo/xproto"
21+)
22+
23+const green = 0x00ff00
24+
25+func main() {
26+ if err := run(); err != nil {
27+ fmt.Fprintln(os.Stderr, "x11probe:", err)
28+ os.Exit(1)
29+ }
30+}
31+
32+func run() error {
33+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
34+ title := flag.String("title", "probe", "_NET_WM_NAME")
35+ size := flag.String("size", "300x200", "window size WxH")
36+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
37+ flag.Parse()
38+ w, h, ok := parseSize(*size)
39+ if !ok {
40+ return fmt.Errorf("bad -size %q", *size)
41+ }
42+
43+ c, err := xturbo.Dial("") // $DISPLAY
44+ if err != nil {
45+ return err
46+ }
47+ defer c.Close()
48+
49+ var atoms [4]uint32
50+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil {
51+ return err
52+ }
53+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
54+
55+ b := c.Buf()
56+ xwin, err := c.NewXID()
57+ if err != nil {
58+ return err
59+ }
60+ xgc, err := c.NewXID()
61+ if err != nil {
62+ return err
63+ }
64+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
65+
66+ var v xproto.CreateWindowValues
67+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)
68+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
69+
70+ wmClass := []byte("x11probe\x00" + *class + "\x00")
71+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
72+ name := []byte(*title)
73+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
74+ var del [4]byte
75+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
76+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
77+
78+ var gv xproto.CreateGCValues
79+ gv.SetForeground(green)
80+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
81+ if err := c.Flush(); err != nil {
82+ return err
83+ }
84+ time.Sleep(*sleep)
85+ xproto.MapWindow(b, win)
86+
87+ mapped, done := false, false
88+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
89+ hd := &xturbo.Handler{
90+ OnEvent: func(ev *xturbo.Event) {
91+ switch ev.Kind {
92+ case xproto.KindExpose:
93+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
94+ case xproto.KindConfigureNotify:
95+ e := xproto.ConfigureNotifyOf(ev)
96+ if e.Window() != win {
97+ break
98+ }
99+ cw, ch = e.Width(), e.Height()
100+ if !mapped {
101+ mapped = true
102+ fmt.Printf("mapped %dx%d\n", cw, ch)
103+ }
104+ case xproto.KindFocusIn:
105+ fmt.Println("focus")
106+ case xproto.KindClientMessage:
107+ e := xproto.ClientMessageOf(ev)
108+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
109+ done = true
110+ }
111+ }
112+ },
113+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
114+ }
115+ for !done {
116+ events := int16(unix.POLLIN)
117+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
118+ events |= unix.POLLOUT
119+ } else if err != nil {
120+ return err
121+ }
122+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
123+ if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {
124+ return err
125+ }
126+ if err := c.Dispatch(hd); err != nil {
127+ return err
128+ }
129+ }
130+ return nil
131+}
132+
133+func parseSize(s string) (w, h int, ok bool) {
134+ ws, hs, found := strings.Cut(s, "x")
135+ if !found {
136+ return 0, 0, false
137+ }
138+ w, err1 := strconv.Atoi(ws)
139+ h, err2 := strconv.Atoi(hs)
140+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
141+}
A internal/app/xwayland_test.go +282 -0
@@ -0,0 +1,282 @@
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+ "image"
12+ "image/png"
13+ "io"
14+ "os"
15+ "os/exec"
16+ "path/filepath"
17+ "regexp"
18+ "strings"
19+ "sync"
20+ "testing"
21+ "time"
22+
23+ "github.com/bnema/zerowrap"
24+
25+ "github.com/bnema/neferwl/internal/adapters/config"
26+ "github.com/bnema/neferwl/internal/adapters/statefile"
27+ "github.com/bnema/neferwl/internal/ports"
28+)
29+
30+const (
31+ xwaylandTimeout = 10 * time.Second
32+ xwaylandW, xwaylandH = 640, 360
33+)
34+
35+var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
36+
37+// probe is a running x11probe.
38+type probe struct {
39+ cmd *exec.Cmd
40+ out *lockedBuffer
41+ waited chan error
42+}
43+
44+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
45+// up in the state with its class and pid, is drawn, tiles next to a second
46+// one, receives focus and is closed by the close-window bind.
47+func TestXwaylandProbe(t *testing.T) {
48+ if _, err := exec.LookPath("Xwayland"); err != nil {
49+ t.Skip("Xwayland not on PATH")
50+ }
51+ // The compositor must not read or write the live session's state.
52+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
53+ if v, ok := os.LookupEnv(k); ok {
54+ os.Unsetenv(k)
55+ t.Cleanup(func() { os.Setenv(k, v) })
56+ }
57+ }
58+ runtime := t.TempDir()
59+ t.Setenv("XDG_RUNTIME_DIR", runtime)
60+ t.Setenv("XDG_STATE_HOME", t.TempDir())
61+ shots := t.TempDir()
62+ bin := buildProbe(t)
63+
64+ script, scriptW := io.Pipe()
65+ logs := &lockedBuffer{}
66+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
67+ cfg := config.Defaults()
68+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
69+ done := make(chan error, 1)
70+ go func() {
71+ done <- Run(ctx, Options{
72+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
73+ ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},
74+ })
75+ }()
76+ t.Cleanup(func() {
77+ if t.Failed() {
78+ t.Logf("compositor log:\n%s", logs.String())
79+ }
80+ cancel()
81+ _ = scriptW.Close()
82+ select {
83+ case err := <-done:
84+ if err != nil {
85+ t.Errorf("Run: %v", err)
86+ }
87+ case <-time.After(5 * time.Second):
88+ t.Error("Run did not stop")
89+ }
90+ })
91+
92+ var display string
93+ until(t, "X11 display in the log", func() bool {
94+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
95+ display = m[1]
96+ return true
97+ }
98+ return false
99+ })
100+ statePath, err := statefile.Path(runtime, "wayland-1")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+
105+ first := startProbe(t, bin, display, "-title", "one")
106+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
107+
108+ var st statefile.State
109+ until(t, "one visible X11Probe window", func() bool {
110+ st = readState(statePath)
111+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
112+ })
113+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
114+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
115+ }
116+
117+ // The client draws green and the compositor shows it.
118+ id := st.Windows[0].ID
119+ until(t, "green window in latest.png", func() bool {
120+ r, ok := tileRect(readState(statePath), id)
121+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
122+ })
123+
124+ // Focus round trip: the compositor focused the window, the client saw it.
125+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
126+
127+ // A second probe tiles beside the first.
128+ second := startProbe(t, bin, display, "-title", "two")
129+ until(t, "two tiled windows", func() bool {
130+ st = readState(statePath)
131+ if len(st.Windows) != 2 {
132+ return false
133+ }
134+ for _, w := range st.Windows {
135+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
136+ return false
137+ }
138+ }
139+ return true
140+ })
141+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
142+
143+ // The close-window bind asks the focused (second) probe to close.
144+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
145+ t.Fatal(err)
146+ }
147+ select {
148+ case err := <-second.waited:
149+ if err != nil {
150+ t.Errorf("probe two exited with %v, want 0", err)
151+ }
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("probe two did not exit within 2s of Super+q")
154+ }
155+ until(t, "one window left", func() bool {
156+ st = readState(statePath)
157+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
158+ })
159+ select {
160+ case err := <-first.waited:
161+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
162+ default:
163+ }
164+}
165+
166+// buildProbe builds testdata/x11probe.
167+func buildProbe(t *testing.T) string {
168+ t.Helper()
169+ bin := filepath.Join(t.TempDir(), "x11probe")
170+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
171+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
172+ if out, err := build.CombinedOutput(); err != nil {
173+ t.Fatalf("build x11probe: %v: %s", err, out)
174+ }
175+ return bin
176+}
177+
178+// startProbe starts x11probe on display, with X11 as its only display
179+// server, and stops it when the test ends.
180+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
181+ t.Helper()
182+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
183+ p.cmd = exec.Command(bin, args...)
184+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
185+ p.cmd.Stdout = p.out
186+ stderr := &lockedBuffer{}
187+ p.cmd.Stderr = stderr
188+ if err := p.cmd.Start(); err != nil {
189+ t.Fatal(err)
190+ }
191+ var exited sync.Once
192+ go func() { p.waited <- p.cmd.Wait() }()
193+ t.Cleanup(func() {
194+ exited.Do(func() {
195+ _ = p.cmd.Process.Kill()
196+ select {
197+ case <-p.waited:
198+ case <-time.After(2 * time.Second):
199+ }
200+ })
201+ if s := stderr.String(); s != "" {
202+ t.Logf("x11probe stderr: %s", s)
203+ }
204+ })
205+ return p
206+}
207+
208+func envWithout(keys ...string) []string {
209+ var env []string
210+next:
211+ for _, kv := range os.Environ() {
212+ for _, k := range keys {
213+ if strings.HasPrefix(kv, k+"=") {
214+ continue next
215+ }
216+ }
217+ env = append(env, kv)
218+ }
219+ return env
220+}
221+
222+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
223+func until(t *testing.T, what string, cond func() bool) {
224+ t.Helper()
225+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
226+ if time.Now().After(deadline) {
227+ t.Fatalf("timeout waiting for %s", what)
228+ }
229+ }
230+}
231+
232+// readState returns the current state file, or an empty state while it does
233+// not exist or is being replaced.
234+func readState(path string) statefile.State {
235+ st, err := statefile.Read(path)
236+ if err != nil {
237+ return statefile.State{}
238+ }
239+ return st
240+}
241+
242+// tileRect finds the rectangle of window id in the tiled columns.
243+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
244+ for _, ws := range st.Workspaces {
245+ for _, c := range ws.Columns {
246+ for _, w := range c.Windows {
247+ if w.ID == id {
248+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
249+ }
250+ }
251+ }
252+ }
253+ return image.Rectangle{}, false
254+}
255+
256+// greenShare is the fraction of pixels of r in the PNG at path that are
257+// clearly green; 0 when the image cannot be read yet.
258+func greenShare(path string, r image.Rectangle) float64 {
259+ f, err := os.Open(path)
260+ if err != nil {
261+ return 0
262+ }
263+ defer f.Close()
264+ img, err := png.Decode(f)
265+ if err != nil {
266+ return 0
267+ }
268+ r = r.Intersect(img.Bounds())
269+ if r.Empty() {
270+ return 0
271+ }
272+ n := 0
273+ for y := r.Min.Y; y < r.Max.Y; y++ {
274+ for x := r.Min.X; x < r.Max.X; x++ {
275+ cr, cg, cb, _ := img.At(x, y).RGBA()
276+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
277+ n++
278+ }
279+ }
280+ }
281+ return float64(n) / float64(r.Dx()*r.Dy())
282+}
M internal/core/core.go +6 -4
@@ -103,9 +103,11 @@ type Core struct {
103103 binds map[binding]Action
104104 pressed map[string]bool
105105 configures configures
106- pointer WindowID
107- grab WindowID
108- buttons map[uint32]bool
106+ // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window.
107+ placed map[WindowID]Rect
108+ pointer WindowID
109+ grab WindowID
110+ buttons map[uint32]bool
109111 // drag is the pointer drag in progress (drag.go); swallow holds the
110112 // buttons whose release no client must see. mods are the modifiers
111113 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
@@ -417,7 +419,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
417419 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
418420 }
419421 // 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()}
422+ 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(), placed: map[WindowID]Rect{}, ping: make(chan chan struct{}), out: newOutbox()}
421423 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
422424 if err := c.apply(cfg); err != nil {
423425 return nil, err
M internal/core/core_test.go +4 -0
@@ -38,6 +38,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
3838 for {
3939 switch v := receive(t, ch); v.(type) {
4040 case nil, ports.SetOutputs, ports.UserActivity:
41+ case ports.PlaceWindow:
42+ // Only Positioned (X11) windows are placed; every scenario
43+ // using this helper maps xdg windows.
44+ t.Fatalf("unexpected %+v", v)
4145 default:
4246 return v
4347 }
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/scene.go +10 -0
@@ -116,6 +116,16 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
116116 }
117117 c.configures.mark(v)
118118 }
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+ }
119129 }
120130 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
121131 if icon, ok := c.dataDragIconOn(i, sc); ok {
M internal/core/transition.go +1 -0
@@ -453,6 +453,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
453453 // The window is unmapped: a toplevel that maps again keeps its ID and
454454 // must be configured from nothing (a leaving entry keeps no configure).
455455 c.configures.forget(v.ID)
456+ delete(c.placed, v.ID)
456457 sc, w := c.screenOf(v.ID)
457458 if sc == nil {
458459 return
M internal/ports/client_commands.go +10 -0
@@ -47,6 +47,16 @@ type ConfigureWindow struct {
4747
4848 func (ConfigureWindow) clientCommand() {}
4949
50+// PlaceWindow carries core → wayland the position of a Positioned window's
51+// client rect, in layout logical coordinates (output origin included). It
52+// follows the ConfigureWindow of a frame and is sent once per change.
53+type PlaceWindow struct {
54+ ID WindowID
55+ X, Y int
56+}
57+
58+func (PlaceWindow) clientCommand() {}
59+
5060 // OutputPlacement is one output in the global layout. X, Y, Width and
5161 // Height are logical (after Transform: they swap for 90/270);
5262 // physical = logical × Scale. Info.Width and Info.Height are the physical
M internal/ports/client_events.go +3 -0
@@ -20,6 +20,9 @@ type WindowMapped struct {
2020 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
2121 // it.
2222 Parent WindowID
23+ // Positioned windows are placed by core: it sends PlaceWindow with the
24+ // client rect origin. Set only by the X11 host, false for xdg.
25+ Positioned bool
2326 // Remap is set when the toplevel mapped before and was unmapped (a tray
2427 // app restored): window rules only place its first map.
2528 Remap bool
M internal/ports/config.go +17 -3
@@ -101,9 +101,8 @@ type Config struct {
101101 Command []string
102102 AutoOpen string
103103 }
104- // Xwayland is the xwayland-satellite binary serving X11 clients; empty
105- // disables X11.
106- Xwayland string
104+ // Xwayland selects the X11 launcher.
105+ Xwayland XwaylandConfig
107106 // Startup are commands run once when the session starts, in order.
108107 // One startup line may hold several, split by SplitCommands.
109108 Startup [][]string
@@ -298,3 +297,18 @@ type WindowRule struct {
298297 // (1 to 16; 0 leaves it automatic).
299298 Cells int
300299 }
300+
301+// XwaylandBackend selects how X11 clients are served.
302+type XwaylandBackend uint8
303+
304+const (
305+ XwaylandOff XwaylandBackend = iota
306+ XwaylandSatellite
307+ XwaylandBuiltin
308+)
309+
310+// XwaylandConfig selects the launcher and its executable.
311+type XwaylandConfig struct {
312+ Backend XwaylandBackend
313+ Binary string
314+}

Keyboard shortcuts

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

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

Branches 25

Tags 10

↑↓ moveenter openesc close