detached 0a100ce

Merge e264045642b9b7340e24f3d336fb638cd69c5375 into 007c134bb83968897ef9efe3d134f65ad337aa79

commit
0a100ce6525ef46efe32fba3a55d9b606f97cd57
author
ermite · · 2026-10-22 15:26 UTC
merge of
007c134 + e264045 · diff against the first parent
signature
unsigned
85 files · +13289 -149
M .mockery.yml +20 -0
@@ -182,3 +182,23 @@ packages:
182 dir: internal/adapters/screencast182 dir: internal/adapters/screencast
183 pkgname: screencast183 pkgname: screencast
184 filename: pipewire_opener_mock_test.go184 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 install1+.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
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
26 CGO_ENABLED=0 go test ./...26 CGO_ENABLED=0 go test ./...
27 vet:27 vet:
28 CGO_ENABLED=0 go vet ./...28 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
29 race:30 race:
30 # cgo is enabled only for the race test binary.31 # cgo is enabled only for the race test binary.
31 CGO_ENABLED=1 go test -race ./...32 CGO_ENABLED=1 go test -race ./...
@@ -69,7 +70,8 @@ adapter-imports:
69 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }70 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
70 # Fast, mandatory guards for tiled content publications and SHM copies.71 # Fast, mandatory guards for tiled content publications and SHM copies.
71 perf-check:72 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=173+ 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|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations)$$' -count=1
74+ CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
73 check: vet test arch fakes-check log-check perf-check75 check: vet test arch fakes-check log-check perf-check
74 76
75 # On-demand SDR/HDR colour check with examples/testpattern on a headless77 # 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
77 color-check:79 color-check:
78 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v80 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
79 81
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+
80 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules87 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
81 # come from the module proxy in prepare(); the build itself runs offline.88 # come from the module proxy in prepare(); the build itself runs offline.
82 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.89 # 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
149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
151 - 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.151 - 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`; shows menus, popups and tooltips; no clipboard or XSETTINGS yet). Wine can run natively on Wayland.
153 153
154 ### Windows and workspaces154 ### Windows and workspaces
155 155
M docs/config.md +3 -1
@@ -27,7 +27,7 @@ See [examples/config](../examples/config) for every key with its default.
27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |27 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |28 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
29 | `startup` | none | Commands run at session start, separated by `;` |29 | `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 |
31 | `background` | `#111111` | Background color |31 | `background` | `#111111` | Background color |
32 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |32 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
33 | `border.width` | `2` | Border width in pixels; `0` hides borders |33 | `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
103 switches and mouse focus do not trigger a flash.103 switches and mouse focus do not trigger a flash.
104 It runs when `animations = on`.104 It runs when `animations = on`.
105 105
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, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. Clipboard and XSETTINGS are not supported; use `satellite` if you need them.
107+
106 ## Automatic terminal108 ## Automatic terminal
107 109
108 The command comes from `terminal`, then `$TERMINAL`, then `foot`.110 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.
52 52
53 ## X11 apps53 ## X11 apps
54 54
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.
56 56
57 ```text57 ```text
58 xwayland = off # no X11 support58 xwayland = off # no X11 support
59 ```59 ```
60 60
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. Clipboard and XSETTINGS are not supported; use `satellite` if you need them.
62+
61 ## Session locking63 ## Session locking
62 64
63 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.65 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 +16 -0
@@ -62,6 +62,22 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本
62 62
63 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.63 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.
64 64
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`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. 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+- an override-redirect menu is drawn magenta above the green toplevel at its X position, is not listed in the state, leaves focus on the toplevel and disappears from the screenshot once the probe unmaps it;
76+- a menu is dismissed when its owner gets `FocusOut` because another X11 window took the focus;
77+- a menu with an empty input shape is still drawn and still not a window.
78+
79+Run it by hand with `go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v`.
80+
65 ## Colour check81 ## Colour check
66 82
67 `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`.83 `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
3 go 1.273 go 1.27
4 4
5 require (5 require (
6+ bnema.dev/xturbo v0.1.0
6 github.com/bnema/go-wayland-bindings v0.1.07 github.com/bnema/go-wayland-bindings v0.1.0
7 github.com/bnema/kvconf v0.1.28 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.19+ github.com/bnema/purego v0.13.0-bnema.2
9- github.com/bnema/purego-libwayland v0.11.010+ github.com/bnema/purego-libwayland v0.12.0
10 github.com/bnema/purego-pipewire v0.2.011 github.com/bnema/purego-pipewire v0.2.0
11 github.com/bnema/purego-vulkan v0.6.012 github.com/bnema/purego-vulkan v0.6.0
12 github.com/bnema/wlturbo v0.5.013 github.com/bnema/wlturbo v0.5.0
@@ -16,7 +17,7 @@ require (
16 github.com/rs/zerolog v1.35.117 github.com/rs/zerolog v1.35.1
17 github.com/stretchr/testify v1.12.118 github.com/stretchr/testify v1.12.1
18 golang.org/x/image v0.46.019 golang.org/x/image v0.46.0
19- golang.org/x/sys v0.48.020+ golang.org/x/sys v0.49.0
20 golang.org/x/term v0.46.021 golang.org/x/term v0.46.0
21 )22 )
22 23
M go.sum +8 -6 generated file: collapsed
M internal/adapters/config/config.go +8 -4
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167 // Hint mode is experimental: off unless the config turns it on.167 // Hint mode is experimental: off unless the config turns it on.
168 c.Hint.Enable = false168 c.Hint.Enable = false
169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}
171 c.Background.Color = "#111111"171 c.Background.Color = "#111111"
172 c.Floating.Dim = 0.3172 c.Floating.Dim = 0.3
173 c.Stash.Width = 80173 c.Stash.Width = 80
@@ -726,11 +726,15 @@ func set(c *ports.Config, key, v string) error {
726 case "xwayland":726 case "xwayland":
727 switch {727 switch {
728 case v == "off":728 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"}
730 case v == "" || strings.ContainsAny(v, " \t"):734 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")
732 default:736 default:
733- c.Xwayland = v737+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v}
734 }738 }
735 case "background":739 case "background":
736 if !color.MatchString(v) {740 if !color.MatchString(v) {
M internal/adapters/config/config_test.go +16 -6
@@ -340,14 +340,24 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340 }340 }
341 341
342 func TestXwayland(t *testing.T) {342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite"}) {
344 t.Fatal(Defaults().Xwayland)344 t.Fatal(Defaults().Xwayland)
345 }345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {346+ for _, tt := range []struct {
347- t.Fatal(c.Xwayland, w)347+ value string
348- }348+ want ports.XwaylandConfig
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {349+ }{
350- t.Fatal(c.Xwayland, w)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+ })
351 }361 }
352 }362 }
353 363
M internal/adapters/vulkan/capture_test.go +3 -0
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358 // slot keeps its sync file for every lease. No mock stands in the measured358 // slot keeps its sync file for every lease. No mock stands in the measured
359 // closure.359 // closure.
360 func TestCaptureCycleAllocations(t *testing.T) {360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361 for _, tc := range []struct {364 for _, tc := range []struct {
362 name string365 name string
363 new func(*testing.T) *Renderer366 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 (
16 // A new scene Seq with one of two windows moved is a partial redraw seeded by16 // A new scene Seq with one of two windows moved is a partial redraw seeded by
17 // sceneDelta and allocates nothing either.17 // sceneDelta and allocates nothing either.
18 func TestRenderSteadyStateAllocations(t *testing.T) {18 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
19 for _, tc := range []struct {22 for _, tc := range []struct {
20 name string23 name string
21 transform ports.BufferTransform24 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145 // keeps one alloc of headroom for drivers whose import path differs.148 // keeps one alloc of headroom for drivers whose import path differs.
146 // Skipped without a GPU or /dev/udmabuf.149 // Skipped without a GPU or /dev/udmabuf.
147 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148 r, err := New(80, 32)154 r, err := New(80, 32)
149 if err != nil {155 if err != nil {
150 t.Skipf("Vulkan unavailable: %v", err)156 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 *
66 }66 }
67 var w *window67 var w *window
68 if reason == "" {68 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 {
70 reason = "not_toplevel"70 reason = "not_toplevel"
71 }71 }
72 }72 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116 116
117 // focusClient is the client with the keyboard focus.117 // focusClient is the client with the keyboard focus.
118 func (s *Server) focusClient() server.Client {118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120- return w.xdg.resource.Client()120+ return w.client()
121 }121 }
122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123 return l.resource.Client()123 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
61 return61 return
62 }62 }
63 w := s.windows[s.seat.pointerFocus]63 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() {
65 return65 return
66 }66 }
67 // Surface-local to window-local (core's, from the geometry origin).67 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry68+ g := w.geometry()
69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})69 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
70 }70 }
71 71
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {80 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
81 return false81 return false
82 }82 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {83+ if !w.mapped || !w.alive() {
84 return false84 return false
85 }85 }
86 sent := false86 sent := false
87 usec := uint64(c.Time / time.Microsecond)87 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {88+ for _, rel := range s.relatives[w.client()] {
89 if rel.Resource.Alive() {89 if rel.Resource.Alive() {
90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))90 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
91 sent = true91 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272 }272 }
273 var want *constraint273 var want *constraint
274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {274 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)) {
276 want = c276 want = c
277 }277 }
278 }278 }
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298 if s.protected() {298 if s.protected() {
299 return299 return
300 }300 }
301- x := c.surface.xdg301+ w := c.surface.toplevelWindow()
302- if x == nil || x.window == nil {302+ if w == nil {
303 return303 return
304 }304 }
305 mode := ports.ConstraintConfine305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308 }308 }
309 r := c.region309 r := c.region
310 if r.W > 0 && r.H > 0 {310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X311+ g := w.geometry()
312- r.Y -= x.geometry.Y312+ r.X -= g.X
313+ r.Y -= g.Y
313 }314 }
314 // Core supports one confinement rect, so this bounds the intersection;315 // Core supports one confinement rect, so this bounds the intersection;
315 // contains uses the exact input region to decide activation.316 // contains uses the exact input region to decide activation.
@@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) {
333 r = box334 r = box
334 }335 }
335 }336 }
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}})
337 }338 }
338 339
339 // contains reports whether the pointer is in the region: a constraint340 // contains reports whether the pointer is in the region: a constraint
340 // activates only there, so it never warps the pointer.341 // activates only there, so it never warps the pointer.
341 func (c *constraint) contains(w *window) bool {342 func (c *constraint) contains(w *window) bool {
342 r := c.region343 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)
344 all, rects := c.surface.effectiveInput()345 all, rects := c.surface.effectiveInput()
345 if !all {346 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)
347 inside := false348 inside := false
348 for _, input := range rects {349 for _, input := range rects {
349 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {350 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 {
363 364
364 // locked reports whether the window's pointer is locked.365 // locked reports whether the window's pointer is locked.
365 func (s *Server) locked(w *window) bool {366 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface367+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367 }368 }
M internal/adapters/wayland/decoration.go +1 -1
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {80 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))81 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
82 for _, w := range d.s.windows {82 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() {
84 w.sendConfigure()84 w.sendConfigure()
85 return85 return
86 }86 }
M internal/adapters/wayland/dialog.go +1 -1
@@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) {
77 }77 }
78 w.modal = modal78 w.modal = modal
79 if w.mapped {79 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})
81 }81 }
82 }82 }
M internal/adapters/wayland/dmabuf.go +3 -2
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223 return "", false223 return "", false
224 }224 }
225 root := toplevelRoot(surf)225 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) {
227 return "", false228 return "", false
228 }229 }
229- return root.xdg.window.last.Output, true230+ return w.last.Output, true
230 }231 }
231 232
232 // scanoutFor is the scanout offer for a surface: its window is fullscreen233 // 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) {}
72 func (s *Server) mappedToplevels() []ports.WindowID {72 func (s *Server) mappedToplevels() []ports.WindowID {
73 ids := make([]ports.WindowID, 0, len(s.windows))73 ids := make([]ports.WindowID, 0, len(s.windows))
74 for id, w := range s.windows {74 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {75+ if w.isToplevel() && w.mapped {
76 ids = append(ids, id)76 ids = append(ids, id)
77 }77 }
78 }78 }
M internal/adapters/wayland/foreigntoplevel.go +6 -6
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }44 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
45 ids := make([]ports.WindowID, 0, len(s.windows))45 ids := make([]ports.WindowID, 0, len(s.windows))
46 for id, w := range s.windows {46 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {47+ if w.isToplevel() && w.mapped {
48 ids = append(ids, id)48 ids = append(ids, id)
49 }49 }
50 }50 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186 186
187 // toplevelChanged updates every manager after w mapped or changed.187 // toplevelChanged updates every manager after w mapped or changed.
188 func (s *Server) toplevelChanged(w *window) {188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {189+ if !w.isToplevel() || !w.mapped {
190 return190 return
191 }191 }
192 for _, m := range s.toplevelManagers {192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220 for _, l := range s.extToplevelLists {220 for _, l := range s.extToplevelLists {
221 for id, h := range l.handles {221 for id, h := range l.handles {
222 w := s.windows[id]222 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 {
224 l.closed(id)224 l.closed(id)
225 }225 }
226 }226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231 for _, m := range s.toplevelManagers {231 for _, m := range s.toplevelManagers {
232 for id, h := range m.handles {232 for id, h := range m.handles {
233 w := s.windows[id]233 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 {
235 m.closed(id)235 m.closed(id)
236 }236 }
237 }237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253 if t.s.protected() || t.closed {253 if t.s.protected() || t.closed {
254 return nil254 return nil
255 }255 }
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 {
257 return w257 return w
258 }258 }
259 return nil259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274 }274 }
275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276 if w := t.window(); w != nil {276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()277+ w.sendClose()
278 }278 }
279 }279 }
280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
M internal/adapters/wayland/frames.go +10 -6
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183 func toplevelRoot(surf *surface) *surface {183 func toplevelRoot(surf *surface) *surface {
184 surf = surf.root()184 surf = surf.root()
185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {185 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()
187 }187 }
188 return surf188 return surf
189 }189 }
@@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface {
193 // for a capture session is invisible.193 // for a capture session is invisible.
194 func (s *Server) invisible(surf *surface) bool {194 func (s *Server) invisible(surf *surface) bool {
195 root := toplevelRoot(surf)195 root := toplevelRoot(surf)
196- return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible196+ w := root.toplevelWindow()
197+ return w != nil && w.hasLast && !w.last.Visible
197 }198 }
198 199
199 // suspended reports that nothing draws the toplevel tree, not even a capture200 // suspended reports that nothing draws the toplevel tree, not even a capture
@@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool {
201 // frame callbacks of its output so the capture keeps moving.202 // frame callbacks of its output so the capture keeps moving.
202 func (s *Server) suspended(surf *surface) bool {203 func (s *Server) suspended(surf *surface) bool {
203 root := toplevelRoot(surf)204 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 {
205 return false207 return false
206 }208 }
207- return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured209+ return !w.last.Visible && !w.last.Captured
208 }210 }
209 211
210 // frameOutput is the pacing class for a surface; "" is the 60 Hz212 // frameOutput is the pacing class for a surface; "" is the 60 Hz
@@ -212,11 +214,13 @@ func (s *Server) suspended(surf *surface) bool {
212 func (s *Server) frameOutput(surf *surface) string {214 func (s *Server) frameOutput(surf *surface) string {
213 surf = toplevelRoot(surf)215 surf = toplevelRoot(surf)
214 switch {216 switch {
215- case surf.xdg != nil && surf.xdg.window != nil:217+ case surf.x11win != nil && surf.x11win.isOverride():
218+ return s.overrideOutput(surf.x11win)
219+ case surf.toplevelWindow() != nil:
216 if s.suspended(surf) {220 if s.suspended(surf) {
217 return suspendedFrameQueue221 return suspendedFrameQueue
218 }222 }
219- return surf.xdg.window.last.Output223+ return surf.toplevelWindow().last.Output
220 case surf.layer != nil && surf.layer.output != nil:224 case surf.layer != nil && surf.layer.output != nil:
221 return surf.layer.output.name()225 return surf.layer.output.name()
222 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:226 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
M internal/adapters/wayland/globals.go +10 -1
@@ -13,6 +13,7 @@ import (
13 func registerGlobals(d *server.Display, o Options, s *Server) error {13 func registerGlobals(d *server.Display, o Options, s *Server) error {
14 for _, register := range []func() error{14 for _, register := range []func() error{
15 func() error { return registerXDG(d, s) },15 func() error { return registerXDG(d, s) },
16+ func() error { return registerXwaylandShell(d, s) },
16 func() error { return registerLayer(d, s) },17 func() error { return registerLayer(d, s) },
17 func() error {18 func() error {
18 // Advertise only with the production owner gate and authoritative19 // Advertise only with the production owner gate and authoritative
@@ -131,8 +132,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131 state.identity = c.server.nextSurface132 state.identity = c.server.nextSurface
132 state.wl = w133 state.wl = w
133 c.server.surfaces[w.Resource] = state134 c.server.surfaces[w.Resource] = state
135+ c.server.surfaceByIdentity[state.identity] = state
134 state.sendScale()136 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+ }
136 }145 }
137 }146 }
138 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {147 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
351 case srcWindow:351 case srcWindow:
352 // Core's state gives the size; before it, the last configure.352 // Core's state gives the size; before it, the last configure.
353 w := c.s.windows[src.window]353 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 {
355 return nil, image.Rectangle{}, false, false355 return nil, image.Rectangle{}, false, false
356 }356 }
357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
M internal/adapters/wayland/outputs.go +72 -1
@@ -42,6 +42,8 @@ type output struct {
42 global *server.Global42 global *server.Global
43 resources []*wayland.Output43 resources []*wayland.Output
44 xdgOutputs []*xdgoutput.ZxdgOutputV144 xdgOutputs []*xdgoutput.ZxdgOutputV1
45+ // xdgOutputsX are the private xdg-outputs of the Xwayland client (X space).
46+ xdgOutputsX []*xdgoutput.ZxdgOutputV1
45 }47 }
46 48
47 func (o *output) name() string { return o.place.Info.Name }49 func (o *output) name() string { return o.place.Info.Name }
@@ -89,6 +91,36 @@ func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) {
89 }91 }
90 }92 }
91 93
94+// sendXDGX sends the private xdg-output state: the layout position moved to
95+// X space and the logical size.
96+func (o *output) sendXDGX(s *Server, x *xdgoutput.ZxdgOutputV1, withName bool) {
97+ ox, oy := s.xspace.Origin()
98+ x.SendLogicalPosition(int32(o.place.X-ox), int32(o.place.Y-oy))
99+ x.SendLogicalSize(int32(o.place.Width), int32(o.place.Height))
100+ if withName && x.Version() >= 2 {
101+ x.SendName(o.name())
102+ x.SendDescription(o.description())
103+ }
104+}
105+
106+// republishXDGX resends every private xdg-output after a layout change, so
107+// Xwayland resizes the root and its RandR outputs.
108+func (s *Server) republishXDGX() {
109+ for _, o := range s.outputs {
110+ for _, x := range o.xdgOutputsX {
111+ o.sendXDGX(s, x, false)
112+ if x.Version() < 3 {
113+ x.SendDone()
114+ }
115+ }
116+ for _, r := range o.resources {
117+ if r.Client() == s.xwayland.client && r.Version() >= 2 {
118+ r.SendDone()
119+ }
120+ }
121+ }
122+}
123+
92 // outputOf returns the output a wl_output resource belongs to, or nil for a124 // outputOf returns the output a wl_output resource belongs to, or nil for a
93 // removed one.125 // removed one.
94 func (s *Server) outputOf(r *wayland.Output) *output {126 func (s *Server) outputOf(r *wayland.Output) *output {
@@ -162,6 +194,10 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
162 changed := !slices.Equal(s.outputPlaces, c.Outputs)194 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163 focusChanged := s.focusedOutput != c.Focused195 focusChanged := s.focusedOutput != c.Focused
164 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)196 s.outputPlaces = append(ports.Layout(nil), c.Outputs...)
197+ xChanged := s.xspace.Set(s.outputPlaces)
198+ if s.xwm != nil {
199+ s.xwm.LayoutChanged(xChanged)
200+ }
165 defer func() {201 defer func() {
166 if changed {202 if changed {
167 s.refreshOutputManagers()203 s.refreshOutputManagers()
@@ -244,6 +280,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
244 for _, surf := range s.surfaces {280 for _, surf := range s.surfaces {
245 surf.sendScale()281 surf.sendScale()
246 }282 }
283+ if changed {
284+ s.republishXDGX()
285+ }
247 s.notifyColorFeedback()286 s.notifyColorFeedback()
248 // Layer surfaces sized from the output follow its new logical size.287 // Layer surfaces sized from the output follow its new logical size.
249 adopted := false288 adopted := false
@@ -279,10 +318,42 @@ type outputHandler struct{}
279 318
280 func (outputHandler) Release(*wayland.Output) {}319 func (outputHandler) Release(*wayland.Output) {}
281 320
321+// registerXDGOutput registers the public zxdg_output_manager_v1 and a second,
322+// private one for Xwayland whose outputs are in X space (see globalVisible).
282 func registerXDGOutput(d *server.Display, s *Server) error {323 func registerXDGOutput(d *server.Display, s *Server) error {
283- return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) {324+ var err error
325+ s.xdgPublic, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
284 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})326 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})
285 })327 })
328+ if err != nil {
329+ return err
330+ }
331+ s.xdgPrivate, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) {
332+ _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManagerX{s})
333+ })
334+ return err
335+}
336+
337+type xdgOutputManagerX struct{ server *Server }
338+
339+func (xdgOutputManagerX) Destroy(*xdgoutput.ZxdgOutputManagerV1) {}
340+func (m xdgOutputManagerX) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, wl *wayland.Output) {
341+ x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{})
342+ if err != nil {
343+ return
344+ }
345+ o := m.server.outputOf(wl)
346+ if o == nil {
347+ return
348+ }
349+ o.xdgOutputsX = append(o.xdgOutputsX, x)
350+ x.OnDestroy = func() { o.xdgOutputsX = removeItem(o.xdgOutputsX, x) }
351+ o.sendXDGX(m.server, x, true)
352+ if r.Version() < 3 || wl.Version() < 2 {
353+ x.SendDone()
354+ } else {
355+ wl.SendDone()
356+ }
286 }357 }
287 358
288 type xdgOutputManager struct{ server *Server }359 type xdgOutputManager struct{ server *Server }
M internal/adapters/wayland/popup.go +2 -2
@@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX
31 var parent *window31 var parent *window
32 if parentXDG != nil {32 if parentXDG != nil {
33 for _, w := range s.windows {33 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 {
35 parent = w35 parent = w
36 }36 }
37 }37 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166 func (s *Server) topGrab(client server.Client, parent *window) bool {166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167 var top *window167 var top *window
168 for _, w := range s.windows {168 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) {
170 top = w170 top = w
171 }171 }
172 }172 }
M internal/adapters/wayland/scale.go +5 -3
@@ -21,12 +21,14 @@ func (s *Server) outputOfSurface(surf *surface) *output {
21 surf = surf.root()21 surf = surf.root()
22 // Popups are on their parent's output.22 // Popups are on their parent's output.
23 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {23 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()
25 }25 }
26 name := ""26 name := ""
27 switch {27 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:28+ case surf.x11win != nil && surf.x11win.isOverride():
29- name = surf.xdg.window.last.Output29+ name = s.overrideOutput(surf.x11win)
30+ case surf.toplevelWindow() != nil:
31+ name = surf.toplevelWindow().last.Output
30 case surf.layer != nil && surf.layer.output != nil:32 case surf.layer != nil && surf.layer.output != nil:
31 return surf.layer.output33 return surf.layer.output
32 case surf.lock != nil:34 case surf.lock != nil:
M internal/adapters/wayland/screencast.go +1 -1
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146 gone := ports.CaptureReasonOutputGone146 gone := ports.CaptureReasonOutputGone
147 if cmd.Window != 0 {147 if cmd.Window != 0 {
148 gone = ports.CaptureReasonWindowGone148 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() {
150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151 open.Window = cmd.Window151 open.Window = cmd.Window
152 }152 }
M internal/adapters/wayland/seat.go +10 -9
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276 }276 }
277 277
278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {278 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() {
280 return nil280 return nil
281 }281 }
282- return s.clientKeyboards(w.xdg.resource.Client())282+ return s.clientKeyboards(w.client())
283 }283 }
284 284
285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -300,8 +300,8 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
300 // already dead. Admission is enforced before entering or delivering.300 // already dead. Admission is enforced before entering or delivering.
301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302 }302 }
303- if w := s.windows[id]; w != nil && w.mapped {303+ if w := s.windows[id]; w != nil && w.mapped && !w.isOverride() {
304- if surf := w.xdg.surfaceResource(); surf != nil {304+ if surf := w.surfaceResource(); surf != nil {
305 return surf, s.windowKeyboards(w)305 return surf, s.windowKeyboards(w)
306 }306 }
307 }307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321 }321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true323+ return w.client(), s.windowPointers(w), true
324 }324 }
325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326 return l.resource.Client(), s.layerPointers(l), true326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336 }336 }
337 if w := s.windows[id]; w != nil {337 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() {
339 x, y := w.surfacePoint(x, y)339 x, y := w.surfacePoint(x, y)
340 return surf, s.windowPointers(w), x, y340 return surf, s.windowPointers(w), x, y
341 }341 }
@@ -455,6 +455,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
455 }455 }
456 }456 }
457 s.textInputFocus()457 s.textInputFocus()
458+ s.x11Focus()
458 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")459 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459 s.updateConstraint()460 s.updateConstraint()
460 }461 }
@@ -543,10 +544,10 @@ func pointerFrame(p *wayland.Pointer) {
543 }544 }
544 }545 }
545 func (s *Server) windowPointers(w *window) []*wayland.Pointer {546 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() {
547 return nil548 return nil
548 }549 }
549- return s.clientPointers(w.xdg.resource.Client())550+ return s.clientPointers(w.client())
550 }551 }
551 552
552 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {553 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
M internal/adapters/wayland/server.go +40 -16
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/neferwl/internal/adapters/captureallow"16 "github.com/bnema/neferwl/internal/adapters/captureallow"
17 "github.com/bnema/neferwl/internal/adapters/clock"17 "github.com/bnema/neferwl/internal/adapters/clock"
18 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"18 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
19+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
19 "github.com/bnema/neferwl/internal/adapters/workspaceid"20 "github.com/bnema/neferwl/internal/adapters/workspaceid"
20 "github.com/bnema/neferwl/internal/ports"21 "github.com/bnema/neferwl/internal/ports"
21 "github.com/bnema/purego-libwayland/server"22 "github.com/bnema/purego-libwayland/server"
@@ -160,13 +161,21 @@ type Server struct {
160 securityStop int161 securityStop int
161 securityWG sync.WaitGroup162 securityWG sync.WaitGroup
162 securityLive atomic.Int32163 securityLive atomic.Int32
163- display *server.Display164+ xwayland xwaylandClient // display owner
164- env procEnv165+ // xdgPublic and xdgPrivate are the two zxdg_output_manager_v1 globals:
165- name string166+ // layout space for clients, X space for Xwayland.
166- cleanup func()167+ xdgPublic, xdgPrivate *server.Global
167- log zerowrap.Logger168+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
168- logDamage bool169+ // slots to their windows. Display goroutine only.
169- channels Channels170+ xwm *xwm.XWM
171+ x11Slots map[xwm.Slot]*window
172+ display *server.Display
173+ env procEnv
174+ name string
175+ cleanup func()
176+ log zerowrap.Logger
177+ logDamage bool
178+ channels Channels
170 // awaiting holds frame callbacks by output name, due at its next179 // awaiting holds frame callbacks by output name, due at its next
171 // frame (frameDue, or its page flip).180 // frame (frameDue, or its page flip).
172 awaiting map[string][]*wayland.Callback181 awaiting map[string][]*wayland.Callback
@@ -214,8 +223,11 @@ type Server struct {
214 // foreignExports are the live xdg-foreign handles (foreign.go).223 // foreignExports are the live xdg-foreign handles (foreign.go).
215 foreignExports map[string]*foreignExport224 foreignExports map[string]*foreignExport
216 surfaces map[*server.Resource]*surface225 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer226+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
218- dmabuf *dmabufGlobal227+ // pairing); kept with surfaces.
228+ surfaceByIdentity map[uint64]*surface
229+ buffers map[*server.Resource]clientBuffer
230+ dmabuf *dmabufGlobal
219 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.231 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220 hdrOutputs map[string]ports.OutputHDR232 hdrOutputs map[string]ports.OutputHDR
221 colorID uint64233 colorID uint64
@@ -281,6 +293,7 @@ type Server struct {
281 workspaceIDs *workspaceid.IDs293 workspaceIDs *workspaceid.IDs
282 outputHeads ports.OutputHeads294 outputHeads ports.OutputHeads
283 outputPlaces ports.Layout295 outputPlaces ports.Layout
296+ xspace xwm.Space // the one layout <-> X mapping, shared with the XWM
284 managementSerial uint32297 managementSerial uint32
285 nextOutputApply uint64298 nextOutputApply uint64
286 outputReplies map[uint64]*outputConfiguration299 outputReplies map[uint64]*outputConfiguration
@@ -366,8 +379,10 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
366 logDamage: opts.LogDamage,379 logDamage: opts.LogDamage,
367 channels: ch,380 channels: ch,
368 surfaces: make(map[*server.Resource]*surface),381 surfaces: make(map[*server.Resource]*surface),
382+ surfaceByIdentity: make(map[uint64]*surface),
369 buffers: make(map[*server.Resource]clientBuffer),383 buffers: make(map[*server.Resource]clientBuffer),
370 windows: make(map[ports.WindowID]*window),384 windows: make(map[ports.WindowID]*window),
385+ x11Slots: make(map[xwm.Slot]*window),
371 layers: make(map[ports.WindowID]*layerSurface),386 layers: make(map[ports.WindowID]*layerSurface),
372 nextWindow: 1,387 nextWindow: 1,
373 eventReady: make(chan struct{}, 1),388 eventReady: make(chan struct{}, 1),
@@ -498,6 +513,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
498 if err = s.openPaceTimer(); err != nil {513 if err = s.openPaceTimer(); err != nil {
499 return abort(err)514 return abort(err)
500 }515 }
516+ d.SetGlobalFilter(s.globalVisible)
501 if err = registerGlobals(d, opts, s); err != nil {517 if err = registerGlobals(d, opts, s); err != nil {
502 return abort(err)518 return abort(err)
503 }519 }
@@ -585,6 +601,12 @@ func (s *Server) Run(ctx context.Context) error {
585 }601 }
586 s.castInventoryChanged()602 s.castInventoryChanged()
587 err := s.display.Run(ctx)603 err := s.display.Run(ctx)
604+ // The display is gone: only the X connection is left to close.
605+ if s.xwm != nil {
606+ s.xwm.CloseConn()
607+ s.xwm = nil
608+ }
609+ s.forgetXwayland()
588 s.stopSecurityContexts()610 s.stopSecurityContexts()
589 wg.Wait()611 wg.Wait()
590 return err612 return err
@@ -774,9 +796,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
774 if w := s.windows[c.ID]; w != nil && w.popup != nil {796 if w := s.windows[c.ID]; w != nil && w.popup != nil {
775 w.popup.dismiss()797 w.popup.dismiss()
776 }798 }
799+ case ports.PlaceWindow:
800+ s.x11Place(c)
777 case ports.ConfigureWindow:801 case ports.ConfigureWindow:
778 w := s.windows[c.ID]802 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {803+ if w == nil || !w.toplevelAlive() || !w.alive() {
780 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")804 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781 return805 return
782 }806 }
@@ -787,16 +811,16 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible811 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788 w.last, w.hasLast = c, true812 w.last, w.hasLast = c, true
789 if visibilityChanged {813 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)814+ s.relocateCallbacks(w.surf())
791 s.dropFeedbacks(time.Now())815 s.dropFeedbacks(time.Now())
792 s.wakePacer() // reconsider FIFO barriers at the new period816 s.wakePacer() // reconsider FIFO barriers at the new period
793 }817 }
794 w.sendConfigure()818 w.sendConfigure()
795 s.toplevelChanged(w)819 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {820+ if scanoutChanged && w.surf() != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)821+ s.dmabuf.resendSurface(w.surf())
798 }822 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {823+ if surf := w.surf(); surf != nil && c.Output != "" {
800 surf.sendTreeScale()824 surf.sendTreeScale()
801 }825 }
802 if scanoutChanged {826 if scanoutChanged {
@@ -817,10 +841,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817 s.setShortcutsInhibit(c)841 s.setShortcutsInhibit(c)
818 case ports.CloseWindow:842 case ports.CloseWindow:
819 w := s.windows[c.ID]843 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {844+ if w == nil || !w.toplevelAlive() {
821 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")845 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822 return846 return
823 }847 }
824- w.toplevel.SendClose()848+ w.sendClose()
825 }849 }
826 }850 }
M internal/adapters/wayland/surface.go +15 -2
@@ -28,6 +28,9 @@ const (
28 roleSessionLock28 roleSessionLock
29 // roleDragIcon is the icon of a data drag (dnd.go).29 // roleDragIcon is the icon of a data drag (dnd.go).
30 roleDragIcon30 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ roleX11
31 )34 )
32 35
33 type surface struct {36 type surface struct {
@@ -47,8 +50,12 @@ type surface struct {
47 formatOpaque bool50 formatOpaque bool
48 kind roleKind51 kind roleKind
49 xdg *xdgSurface52 xdg *xdgSurface
50- layer *layerSurface53+ // x11win is the X11 window the surface is associated with; x11Serial is
51- lock *lockSurface54+ // the serial of xwayland_surface_v1.set_serial, 0 until set.
55+ x11win *window
56+ x11Serial uint64
57+ layer *layerSurface
58+ lock *lockSurface
52 // bufferCommitted is permanent, including commits later discarded by FIFO.59 // bufferCommitted is permanent, including commits later discarded by FIFO.
53 bufferCommitted bool60 bufferCommitted bool
54 server *Server61 server *Server
@@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
192 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
193 return s.xdg.window.id200 return s.xdg.window.id
194 }201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
195 if s.layer != nil && s.layer.mapped {205 if s.layer != nil && s.layer.mapped {
196 return s.layer.id206 return s.layer.id
197 }207 }
@@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
566 if s.xdg != nil && s.xdg.window != nil {576 if s.xdg != nil && s.xdg.window != nil {
567 s.xdg.window.afterCommit()577 s.xdg.window.afterCommit()
568 }578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
569 if s.lock != nil {582 if s.lock != nil {
570 // Role callbacks precede content construction; publish lock placement583 // Role callbacks precede content construction; publish lock placement
571 // only once validated current dimensions are available.584 // 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 && !w.x11.override) }
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 (
55 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"55 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
56 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"56 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
57 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"57 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
58+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
58 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"59 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
59 "github.com/bnema/purego-libwayland/server"60 "github.com/bnema/purego-libwayland/server"
60 "github.com/bnema/wlturbo"61 "github.com/bnema/wlturbo"
@@ -253,6 +254,8 @@ var wireSchemas = map[string]wireSchema{
253 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},254 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},255 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},256 "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},
256 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},259 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},260 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258 }261 }
A internal/adapters/wayland/x11override_test.go +107 -0
@@ -0,0 +1,107 @@
1+package wayland
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
8+ "github.com/bnema/neferwl/internal/ports"
9+)
10+
11+const ovWin = 0x500002
12+
13+// overrideWindow maps an override-redirect X window and pairs its surface.
14+func (r *x11Rig) overrideWindow(win uint32, x, y int16, serial uint64) uint32 {
15+ r.t.Helper()
16+ r.peer.createOverride(win, x, y)
17+ r.peer.mapNotify(win)
18+ return r.pair(win, serial)
19+}
20+
21+func (r *x11Rig) state(f func()) {
22+ r.t.Helper()
23+ r.s.display.Do(f)
24+}
25+
26+func TestOverrideMapsOnFirstCommitAndMoves(t *testing.T) {
27+ r := newX11Rig(t)
28+ r.overrideWindow(ovWin, 30, 40, x11Serial)
29+
30+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
31+ if m.X != 30 || m.Y != 40 || m.Width != 100 || m.Height != 50 || m.NoInput {
32+ t.Fatalf("mapped %+v", m)
33+ }
34+ r.state(func() {
35+ w := r.s.windows[m.ID]
36+ if w == nil || !w.isOverride() || w.isToplevel() || !w.mapped {
37+ t.Errorf("override window %+v", w)
38+ }
39+ if surf, _ := r.s.focusTarget(m.ID); surf != nil {
40+ t.Error("override window is a keyboard focus target")
41+ }
42+ })
43+
44+ r.peer.configureNotify(ovWin, 60, 70, 100, 50)
45+ if mv := awaitEvent[ports.UnmanagedMoved](t, r.events); mv.ID != m.ID || mv.X != 60 || mv.Y != 70 {
46+ t.Fatalf("moved %+v", mv)
47+ }
48+
49+ r.peer.unmapNotify(ovWin)
50+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
51+ t.Fatalf("unmapped %+v", u)
52+ }
53+ r.state(func() {
54+ if r.s.windows[m.ID] != nil {
55+ t.Error("override window kept after unmap")
56+ }
57+ })
58+}
59+
60+func TestOverrideSurfaceDestroyUnmaps(t *testing.T) {
61+ r := newX11Rig(t)
62+ surf := r.overrideWindow(ovWin, 0, 0, x11Serial)
63+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
64+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
65+ if err := r.xc.Roundtrip(); err != nil {
66+ t.Fatal(err)
67+ }
68+ if u := awaitEvent[ports.UnmanagedUnmapped](t, r.events); u.ID != m.ID {
69+ t.Fatalf("unmapped %+v", u)
70+ }
71+}
72+
73+func TestOverrideTakesPointerAndOutput(t *testing.T) {
74+ r := newX11Rig(t)
75+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
76+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
77+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
78+ }, Focused: "A"}
79+ r.overrideWindow(ovWin, 100, 10, x11Serial)
80+ m := awaitEvent[ports.UnmanagedMapped](t, r.events)
81+ r.state(func() {
82+ w := r.s.windows[m.ID]
83+ if got := r.s.frameOutput(w.x11.surface); got != "A" {
84+ t.Errorf("frame output %q, want A", got)
85+ }
86+ if surf, _, _, _ := r.s.pointerSurface(m.ID, 1, 1); surf == nil {
87+ t.Error("pointer cannot enter the override surface")
88+ }
89+ })
90+ r.peer.configureNotify(ovWin, 2000, 10, 100, 50)
91+ awaitEvent[ports.UnmanagedMoved](t, r.events)
92+ r.state(func() {
93+ if got := r.s.frameOutput(r.s.windows[m.ID].x11.surface); got != "B" {
94+ t.Errorf("frame output %q after move, want B", got)
95+ }
96+ })
97+}
98+
99+func TestOverrideMovedUnchangedAllocations(t *testing.T) {
100+ s := &Server{windows: map[ports.WindowID]*window{}, x11Slots: map[xwm.Slot]*window{}}
101+ w := &window{id: 1, x11: &x11Window{override: true, rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}}}
102+ s.x11Slots[1] = w
103+ h := hostImpl{s}
104+ if n := testing.AllocsPerRun(1000, func() { h.OverrideMoved(1, 1, 2, 3, 4) }); n != 0 {
105+ t.Fatalf("%v allocs", n)
106+ }
107+}
A internal/adapters/wayland/x11peer_test.go +332 -0
@@ -0,0 +1,332 @@
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+// createOverride announces an override-redirect window of 100x50 at x,y.
248+func (p *x11Peer) createOverride(win uint32, x, y int16) {
249+ p.event(16, func(b *[32]byte) {
250+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
251+ binary.LittleEndian.PutUint32(b[8:], win)
252+ binary.LittleEndian.PutUint16(b[12:], uint16(x))
253+ binary.LittleEndian.PutUint16(b[14:], uint16(y))
254+ binary.LittleEndian.PutUint16(b[16:], 100)
255+ binary.LittleEndian.PutUint16(b[18:], 50)
256+ b[22] = 1
257+ })
258+}
259+
260+func (p *x11Peer) mapNotify(win uint32) {
261+ p.event(19, func(b *[32]byte) {
262+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
263+ binary.LittleEndian.PutUint32(b[8:], win)
264+ b[12] = 1
265+ })
266+}
267+
268+// configureNotify moves and resizes a window (root SubstructureNotify form).
269+func (p *x11Peer) configureNotify(win uint32, x, y int16, w, h uint16) {
270+ p.event(22, func(b *[32]byte) {
271+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
272+ binary.LittleEndian.PutUint32(b[8:], win)
273+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
274+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
275+ binary.LittleEndian.PutUint16(b[20:], w)
276+ binary.LittleEndian.PutUint16(b[22:], h)
277+ b[24] = 1
278+ })
279+}
280+
281+func (p *x11Peer) windowEvent(code uint8, win uint32) {
282+ p.event(code, func(b *[32]byte) {
283+ binary.LittleEndian.PutUint32(b[4:], x11PeerRoot)
284+ binary.LittleEndian.PutUint32(b[8:], win)
285+ })
286+}
287+
288+func (p *x11Peer) mapRequest(win uint32) { p.windowEvent(20, win) }
289+func (p *x11Peer) unmapNotify(win uint32) { p.windowEvent(18, win) }
290+func (p *x11Peer) destroyNotify(win uint32) { p.windowEvent(17, win) }
291+
292+func (p *x11Peer) propertyNotify(win, atom uint32) {
293+ p.event(28, func(b *[32]byte) {
294+ binary.LittleEndian.PutUint32(b[4:], win)
295+ binary.LittleEndian.PutUint32(b[8:], atom)
296+ })
297+}
298+
299+func (p *x11Peer) clientMessage(win uint32, typ string, data ...uint32) {
300+ t := p.atom(typ)
301+ p.event(33, func(b *[32]byte) {
302+ b[1] = 32
303+ binary.LittleEndian.PutUint32(b[4:], win)
304+ binary.LittleEndian.PutUint32(b[8:], t)
305+ for i, d := range data {
306+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
307+ }
308+ })
309+}
310+
311+func (p *x11Peer) requests() []x11Req {
312+ p.mu.Lock()
313+ defer p.mu.Unlock()
314+ return append([]x11Req(nil), p.reqs...)
315+}
316+
317+// waitReq waits for a request after the first skip ones that satisfies match.
318+func (p *x11Peer) waitReq(skip int, match func(x11Req) bool) (x11Req, int) {
319+ p.t.Helper()
320+ deadline := time.Now().Add(5 * time.Second)
321+ for time.Now().Before(deadline) {
322+ reqs := p.requests()
323+ for i := skip; i < len(reqs); i++ {
324+ if match(reqs[i]) {
325+ return reqs[i], i + 1
326+ }
327+ }
328+ time.Sleep(time.Millisecond)
329+ }
330+ p.t.Fatal("x11 peer: expected request never arrived")
331+ return x11Req{}, 0
332+}
A internal/adapters/wayland/x11window.go +439 -0
@@ -0,0 +1,439 @@
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+ // override marks an override-redirect window: tracked and drawn, never a
22+ // toplevel nor keyboard focus. rect is its layout rect, noInput keeps the
23+ // pointer off it.
24+ override bool
25+ noInput bool
26+ rect ports.Rect
27+}
28+
29+// xwaylandRestricted are the globals only the Xwayland client may see.
30+func xwaylandRestricted(iface string) bool {
31+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
32+}
33+
34+// globalVisible is the display's global filter: the Xwayland-only globals
35+// are visible to, and bindable by, the Xwayland client alone.
36+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
37+ if g == s.xdgPrivate {
38+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
39+ }
40+ if g == s.xdgPublic {
41+ return c != s.xwayland.client
42+ }
43+ if xwaylandRestricted(g.Interface()) {
44+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
45+ }
46+ return true
47+}
48+
49+func registerXwaylandShell(d *server.Display, s *Server) error {
50+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
51+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
52+ })
53+}
54+
55+type xwaylandShell struct{ s *Server }
56+
57+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
58+
59+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
60+ surf := h.s.surfaceOf(w)
61+ if surf == nil {
62+ return
63+ }
64+ if surf.kind != roleNone {
65+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
66+ return
67+ }
68+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
69+ return
70+ }
71+ surf.kind = roleX11
72+}
73+
74+type xwaylandSurface struct {
75+ s *Server
76+ surf *surface
77+}
78+
79+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
80+ serial := uint64(hi)<<32 | uint64(lo)
81+ switch {
82+ case x.surf.x11Serial != 0:
83+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
84+ case serial == 0:
85+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
86+ default:
87+ x.surf.x11Serial = serial
88+ if x.s.xwm != nil {
89+ x.s.xwm.SetSerial(x.surf.identity, serial)
90+ }
91+ }
92+}
93+
94+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
95+
96+// installXWM makes x the server's window manager. Display goroutine only.
97+func (s *Server) installXWM(x *xwm.XWM) {
98+ s.teardownXWM()
99+ s.xwm = x
100+ x.UseSpace(&s.xspace)
101+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
102+ s.log.Warn().Err(err).Msg("X11 window management disabled")
103+ s.xwm = nil
104+ x.CloseConn()
105+ return
106+ }
107+ // Serials sent before the XWM was attached pair now.
108+ for _, surf := range s.surfaces {
109+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
110+ x.SetSerial(surf.identity, surf.x11Serial)
111+ }
112+ }
113+ x.LayoutChanged(true)
114+}
115+
116+// teardownXWM closes the window manager, dropping its windows. Idempotent.
117+func (s *Server) teardownXWM() {
118+ if x := s.xwm; x != nil {
119+ s.xwm = nil
120+ x.Close()
121+ }
122+}
123+
124+// x11Configure forwards the latest core configure of an X11 window.
125+func (s *Server) x11Configure(w *window) {
126+ if s.xwm != nil {
127+ s.xwm.Configure(w.x11.slot, w.last)
128+ }
129+}
130+
131+// x11Close asks an X11 window to close.
132+func (s *Server) x11Close(w *window) {
133+ if s.xwm != nil {
134+ s.xwm.CloseWindow(w.x11.slot)
135+ }
136+}
137+
138+// x11Place forwards core's position of an X11 window's client rect.
139+func (s *Server) x11Place(c ports.PlaceWindow) {
140+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
141+ s.xwm.Place(w.x11.slot, c.X, c.Y)
142+ }
143+}
144+
145+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
146+// native window does.
147+func (s *Server) x11Focus() {
148+ if s.xwm == nil {
149+ return
150+ }
151+ var slot xwm.Slot
152+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
153+ slot = w.x11.slot
154+ }
155+ s.xwm.Focus(slot)
156+}
157+
158+// hostImpl is the server as the XWM sees it.
159+type hostImpl struct{ s *Server }
160+
161+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
162+
163+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
164+ s := h.s
165+ w := &window{id: s.nextWindow}
166+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
167+ s.nextWindow++
168+ s.windows[w.id] = w
169+ s.x11Slots[slot] = w
170+ return w.id
171+}
172+
173+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
174+ w := h.window(slot)
175+ if w == nil {
176+ return
177+ }
178+ w.x11.info, w.x11.ready = info, true
179+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
180+ w.parent = h.window(info.Parent)
181+ // A surface associated and committed earlier maps now.
182+ w.x11.tryMap()
183+}
184+
185+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
186+ w := h.window(slot)
187+ surf := h.s.surfaceByIdentity[surfaceID]
188+ if w == nil || surf == nil || surf.kind != roleX11 {
189+ return
190+ }
191+ x := w.x11
192+ if x.surface != nil && x.surface != surf {
193+ // A new surface replaces the shown one: core sees a remap.
194+ w.unmap()
195+ }
196+ x.drop()
197+ x.surface, surf.x11win = surf, w
198+ surf.role = func(buffer bool) {
199+ switch {
200+ case surf.destroyed:
201+ w.unmap()
202+ if x.surface == surf {
203+ x.surface = nil
204+ }
205+ surf.x11win = nil
206+ case buffer:
207+ x.tryMap()
208+ case w.mapped:
209+ w.unmap()
210+ }
211+ }
212+ // The buffer may have been committed before the pairing.
213+ x.tryMap()
214+}
215+
216+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
217+ w := h.window(slot)
218+ if w == nil {
219+ return
220+ }
221+ w.unmap()
222+ w.x11.drop()
223+ w.x11.ready = false
224+ if destroyed {
225+ for _, c := range h.s.windows {
226+ if c.parent == w {
227+ c.parent = nil
228+ }
229+ }
230+ delete(h.s.windows, w.id)
231+ delete(h.s.x11Slots, slot)
232+ h.s.foreignToplevelGone(w)
233+ }
234+}
235+
236+func (h hostImpl) Title(slot xwm.Slot, title string) {
237+ if w := h.window(slot); w != nil && w.title != title {
238+ w.title = title
239+ h.s.toplevelChanged(w)
240+ if w.mapped {
241+ h.s.castInventoryChanged()
242+ }
243+ }
244+}
245+
246+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
247+ w := h.window(slot)
248+ if w == nil || w.appID == appID {
249+ return
250+ }
251+ w.appID = appID
252+ if w.mapped {
253+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
254+ h.s.toplevelChanged(w)
255+ h.s.castInventoryChanged()
256+ }
257+}
258+
259+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
260+ if w := h.window(slot); w != nil {
261+ w.setParent(h.window(parent))
262+ }
263+}
264+
265+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
266+ w := h.window(slot)
267+ if w == nil {
268+ return
269+ }
270+ w.x11.info.Fullscreen = on
271+ if w.mapped {
272+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
273+ }
274+}
275+
276+// drop unlinks the window from its surface.
277+func (x *x11Window) drop() {
278+ if surf := x.surface; surf != nil {
279+ surf.x11win, surf.role = nil, nil
280+ x.surface = nil
281+ }
282+}
283+
284+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
285+func (x *x11Window) tryMap() {
286+ surf := x.surface
287+ if surf == nil {
288+ return
289+ }
290+ w := surf.x11win
291+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
292+ return
293+ }
294+ s := x.server
295+ w.mapped = true
296+ w.gen++
297+ w.floating = x.info.Floating
298+ w.floatW, w.floatH = 0, 0
299+ if w.floating {
300+ w.floatW, w.floatH = x.info.Width, x.info.Height
301+ }
302+ var parent ports.WindowID
303+ if w.parent != nil && w.parent.mapped {
304+ parent = w.parent.id
305+ }
306+ 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})
307+ if x.info.Fullscreen {
308+ s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true})
309+ }
310+ s.syncInhibitors()
311+ s.toplevelChanged(w)
312+ s.castInventoryChanged()
313+ 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")
314+ // Called from a commit, the content follows right after; called from the
315+ // pairing, the surface already shows a buffer and is published now.
316+ if surf.has {
317+ surf.resetInputEmission()
318+ surf.redraw()
319+ surf.emitInput()
320+ }
321+}
322+
323+// OverrideMapped starts tracking an override-redirect window. It is announced
324+// to core on the paired surface's first buffer commit, so it is never drawn
325+// without content.
326+func (h hostImpl) OverrideMapped(slot xwm.Slot, xid uint32, surfaceID uint64, x, y, w, h2 int, noInput bool) {
327+ s := h.s
328+ surf := s.surfaceByIdentity[surfaceID]
329+ if surf == nil || surf.kind != roleX11 || surf.x11win != nil {
330+ return
331+ }
332+ if old := s.x11Slots[slot]; old != nil {
333+ h.OverrideGone(slot)
334+ }
335+ win := &window{id: s.nextWindow}
336+ win.x11 = &x11Window{server: s, slot: slot, xid: xid, override: true, noInput: noInput, rect: ports.Rect{X: x, Y: y, W: w, H: h2}}
337+ s.nextWindow++
338+ s.windows[win.id] = win
339+ s.x11Slots[slot] = win
340+ ox := win.x11
341+ ox.surface, surf.x11win = surf, win
342+ surf.role = func(buffer bool) {
343+ switch {
344+ case surf.destroyed:
345+ ox.hide()
346+ ox.surface = nil
347+ surf.x11win = nil
348+ case buffer:
349+ ox.show()
350+ case win.mapped:
351+ ox.hide()
352+ }
353+ }
354+ ox.show()
355+}
356+
357+// OverrideMoved records the new layout rect of an override window and, once
358+// shown, tells core. An unchanged rect costs nothing.
359+func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
360+ win := h.s.x11Slots[slot]
361+ if win == nil || win.x11 == nil || !win.x11.override {
362+ return
363+ }
364+ r := ports.Rect{X: x, Y: y, W: w, H: h2}
365+ if r == win.x11.rect {
366+ return
367+ }
368+ win.x11.rect = r
369+ if win.mapped {
370+ h.s.emit(ports.UnmanagedMoved{ID: win.id, X: x, Y: y, Width: w, Height: h2})
371+ win.x11.surface.sendTreeScale()
372+ }
373+}
374+
375+// OverrideGone ends an override window: unmapped, or destroyed.
376+func (h hostImpl) OverrideGone(slot xwm.Slot) {
377+ s := h.s
378+ win := s.x11Slots[slot]
379+ if win == nil || win.x11 == nil || !win.x11.override {
380+ return
381+ }
382+ win.x11.hide()
383+ win.x11.drop()
384+ delete(s.windows, win.id)
385+ delete(s.x11Slots, slot)
386+}
387+
388+// show announces the window to core once its surface has content.
389+func (x *x11Window) show() {
390+ surf := x.surface
391+ if surf == nil || surf.x11win == nil || surf.destroyed || surf.current == nil {
392+ return
393+ }
394+ w := surf.x11win
395+ if w.mapped {
396+ return
397+ }
398+ s := x.server
399+ w.mapped = true
400+ r := x.rect
401+ s.emit(ports.UnmanagedMapped{ID: w.id, X: r.X, Y: r.Y, Width: r.W, Height: r.H, NoInput: x.noInput})
402+ surf.sendTreeScale()
403+ if surf.has {
404+ surf.resetInputEmission()
405+ surf.redraw()
406+ surf.emitInput()
407+ }
408+}
409+
410+// hide withdraws a shown override window from core and the seat.
411+func (x *x11Window) hide() {
412+ if x.surface == nil || x.surface.x11win == nil {
413+ return
414+ }
415+ w := x.surface.x11win
416+ s := x.server
417+ if s.seat.pointerFocus == w.id {
418+ s.changePointerFocus(0, 0, 0)
419+ }
420+ if !w.mapped {
421+ return
422+ }
423+ w.mapped = false
424+ x.surface.resetInputEmission()
425+ s.emit(ports.UnmanagedUnmapped{ID: w.id})
426+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
427+}
428+
429+// isOverride reports whether w is an override-redirect X11 window.
430+func (w *window) isOverride() bool { return w.x11 != nil && w.x11.override }
431+
432+// overrideOutput is the name of the output under the origin of an override
433+// window's rect, "" when none.
434+func (s *Server) overrideOutput(w *window) string {
435+ if o, ok := s.outputPlaces.At(float64(w.x11.rect.X), float64(w.x11.rect.Y)); ok {
436+ return o.Info.Name
437+ }
438+ return ""
439+}
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) {
128 128
129 // window is an xdg_toplevel, or an xdg_popup when popup is set.129 // window is an xdg_toplevel, or an xdg_popup when popup is set.
130 type window struct {130 type window struct {
131- id ports.WindowID131+ id ports.WindowID
132- toplevel *xdgshell.Toplevel132+ toplevel *xdgshell.Toplevel
133- popup *popup133+ popup *popup
134- xdg *xdgSurface134+ xdg *xdgSurface
135+ // x11 is set for the window of an X11 client (x11window.go).
136+ x11 *x11Window
135 appID, title string137 appID, title string
136 mapped bool138 mapped bool
137 // gen counts the mappings: an unmap and a remap is a new toplevel to139 // gen counts the mappings: an unmap and a remap is a new toplevel to
@@ -151,8 +153,8 @@ type window struct {
151 hasLast bool153 hasLast bool
152 }154 }
153 155
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.156+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {157+func (w *window) sendXDGConfigure() {
156 c := w.last158 c := w.last
157 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))159 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158 s := w.xdg.server160 s := w.xdg.server
@@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
192 }194 }
193 195
194 func (w *window) unmap() {196 func (w *window) unmap() {
195- if w.xdg.server.seat.pointerFocus == w.id {197+ s := w.srv()
196- w.xdg.server.changePointerFocus(0, 0, 0)198+ if s.seat.pointerFocus == w.id {
199+ s.changePointerFocus(0, 0, 0)
197 }200 }
198- if w.xdg.server.seat.focused == w.id {201+ if s.seat.focused == w.id {
199- w.xdg.server.changeFocus(0)202+ s.changeFocus(0)
200 }203 }
201 if !w.mapped {204 if !w.mapped {
202 return205 return
203 }206 }
204 w.mapped = false207 w.mapped = false
205- w.xdg.server.toplevelClosed(w.id)208+ s.toplevelClosed(w.id)
206- w.xdg.surface.resetInputEmission()209+ if surf := w.surf(); surf != nil {
207- w.xdg.configured = false210+ surf.resetInputEmission()
208- w.xdg.acked = false211+ }
209- w.xdg.serials = nil212+ if x := w.xdg; x != nil {
210- w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})213+ x.configured = false
211- w.xdg.server.syncInhibitors()214+ x.acked = false
212- w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})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})
213 }220 }
214 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {221 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215 if x.window != nil {222 if x.window != nil {
@@ -337,7 +344,7 @@ func (w *window) setParent(p *window) {
337 if p != nil && p.mapped {344 if p != nil && p.mapped {
338 id = p.id345 id = p.id
339 }346 }
340- w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})347+ w.srv().emit(ports.WindowParent{ID: w.id, Parent: id})
341 }348 }
342 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {349 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
343 if title == t.w.title {350 if title == t.w.title {
@@ -417,7 +424,7 @@ func (w *window) afterCommit() {
417 }424 }
418 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {425 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
419 w.floatW, w.floatH = fw, fh426 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})
421 }428 }
422 }429 }
423 430
@@ -431,6 +438,10 @@ func (w *window) floats() bool {
431 438
432 // size is the window geometry size, else the surface size, in logical pixels.439 // size is the window geometry size, else the surface size, in logical pixels.
433 func (w *window) size() (int, int) {440 func (w *window) size() (int, int) {
441+ if w.xdg == nil {
442+ c := w.surf().content
443+ return c.LogicalW, c.LogicalH
444+ }
434 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {445 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {
435 return g.W, g.H446 return g.W, g.H
436 }447 }
@@ -441,7 +452,7 @@ func (w *window) size() (int, int) {
441 // surfacePoint turns window coordinates (core's, from the geometry origin)452 // surfacePoint turns window coordinates (core's, from the geometry origin)
442 // into surface coordinates.453 // into surface coordinates.
443 func (w *window) surfacePoint(x, y float64) (float64, float64) {454 func (w *window) surfacePoint(x, y float64) (float64, float64) {
444- g := w.xdg.geometry455+ g := w.geometry()
445 return x + float64(g.X), y + float64(g.Y)456 return x + float64(g.X), y + float64(g.Y)
446 }457 }
447 458
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
@@ -0,0 +1,143 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/purego-libwayland/server"
9+ "github.com/bnema/wlturbo"
10+)
11+
12+// xdgPosProxy records the logical_position events of one zxdg_output_v1.
13+type xdgPosProxy struct {
14+ wlturbo.BaseProxy
15+ pos [][2]int32
16+}
17+
18+func (p *xdgPosProxy) Dispatch(e *wlturbo.Event) {
19+ if e.Opcode == 0 {
20+ p.pos = append(p.pos, [2]int32{e.Int32(), e.Int32()})
21+ }
22+}
23+
24+// xdgOutputsOf binds the xdg-output manager and an xdg-output for every
25+// wl_output of c, and returns what each one reported.
26+func xdgOutputsOf(t *testing.T, c *wlturbo.Display) []*xdgPosProxy {
27+ t.Helper()
28+ if err := c.Roundtrip(); err != nil {
29+ t.Fatal(err)
30+ }
31+ var mgrs int
32+ var manager uint32
33+ for _, g := range c.Registry().GetGlobals() {
34+ if g.Interface == "zxdg_output_manager_v1" {
35+ mgrs++
36+ id, err := bindWireID(c, g.Name, g.Interface, 3)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ registerProtocol(t, c, id)
41+ manager = id
42+ }
43+ }
44+ if mgrs != 1 {
45+ t.Fatalf("client sees %d zxdg_output_manager_v1 globals, want 1", mgrs)
46+ }
47+ var out []*xdgPosProxy
48+ for _, g := range c.Registry().GetGlobals() {
49+ if g.Interface != "wl_output" {
50+ continue
51+ }
52+ wo, err := bindWireID(c, g.Name, g.Interface, 4)
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ xp := &xdgPosProxy{}
57+ xp.SetID(c.AllocateID())
58+ registerWireProxy(c, xp)
59+ requestProtocol(t, c, manager, 1, xp.ID(), wo)
60+ out = append(out, xp)
61+ }
62+ if err := c.Roundtrip(); err != nil {
63+ t.Fatal(err)
64+ }
65+ return out
66+}
67+
68+func lastPositions(ps []*xdgPosProxy) [][2]int32 {
69+ var r [][2]int32
70+ for _, p := range ps {
71+ r = append(r, p.pos[len(p.pos)-1])
72+ }
73+ slices.SortFunc(r, func(a, b [2]int32) int { return int(a[0] - b[0]) })
74+ return r
75+}
76+
77+func twoOutputs(ax int) ports.SetOutputs {
78+ mk := func(name string, x int) ports.OutputPlacement {
79+ return ports.OutputPlacement{Info: ports.OutputInfo{Name: name, Width: 1920, Height: 1080}, X: x, Y: 0, Width: 1920, Height: 1080, Scale: 1}
80+ }
81+ return ports.SetOutputs{Outputs: ports.Layout{mk("A", ax), mk("B", 0)}}
82+}
83+
84+// Xwayland gets X-space positions (layout top-left is 0,0) and re-receives
85+// them on a layout change; every other client keeps the layout coordinates.
86+func TestXwaylandPrivateXDGOutput(t *testing.T) {
87+ r := newX11Rig(t)
88+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
89+ other := protocolClient(t, r.s, r.dir)
90+ xo := xdgOutputsOf(t, r.xc)
91+ oo := xdgOutputsOf(t, other)
92+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {1920, 0}}; !slices.Equal(got, want) {
93+ t.Errorf("Xwayland positions %v, want %v", got, want)
94+ }
95+ if got, want := lastPositions(oo), [][2]int32{{-1920, 0}, {0, 0}}; !slices.Equal(got, want) {
96+ t.Errorf("ordinary positions %v, want %v", got, want)
97+ }
98+ applyOutputs(t, r.s, r.commands, twoOutputs(-3840))
99+ if err := r.xc.Roundtrip(); err != nil {
100+ t.Fatal(err)
101+ }
102+ if err := other.Roundtrip(); err != nil {
103+ t.Fatal(err)
104+ }
105+ if got, want := lastPositions(xo), [][2]int32{{0, 0}, {3840, 0}}; !slices.Equal(got, want) {
106+ t.Errorf("Xwayland positions after change %v, want %v", got, want)
107+ }
108+ if got, want := lastPositions(oo), [][2]int32{{-3840, 0}, {0, 0}}; !slices.Equal(got, want) {
109+ t.Errorf("ordinary positions after change %v, want %v", got, want)
110+ }
111+ // Hotplug: a third output on the right grows the extents.
112+ third := twoOutputs(-3840)
113+ third.Outputs = append(third.Outputs, ports.OutputPlacement{Info: ports.OutputInfo{Name: "C", Width: 1920, Height: 1080}, X: 1920, Width: 1920, Height: 1080, Scale: 1})
114+ applyOutputs(t, r.s, r.commands, third)
115+ if err := r.xc.Roundtrip(); err != nil {
116+ t.Fatal(err)
117+ }
118+ if got := lastPositions(xo); len(got) != 2 || got[1] != [2]int32{3840, 0} {
119+ t.Errorf("Xwayland positions after hotplug %v", got)
120+ }
121+}
122+
123+func TestGlobalFilterAllocations(t *testing.T) {
124+ if raceEnabled {
125+ t.Skip("the race detector allocates in the background")
126+ }
127+ s := &Server{xdgPublic: &server.Global{}, xdgPrivate: &server.Global{}}
128+ if allocs := testing.AllocsPerRun(100, func() {
129+ s.globalVisible(server.Client{}, s.xdgPrivate)
130+ s.globalVisible(server.Client{}, s.xdgPublic)
131+ }); allocs != 0 {
132+ t.Errorf("globalVisible: %.1f allocs, want 0", allocs)
133+ }
134+}
135+
136+// The X origin is derived by one Space, kept without a window manager.
137+func TestXSpaceFollowsLayoutWithoutXWM(t *testing.T) {
138+ r := newX11Rig(t)
139+ applyOutputs(t, r.s, r.commands, twoOutputs(-1920))
140+ if ox, oy := r.s.xspace.Origin(); ox != -1920 || oy != 0 {
141+ t.Fatalf("origin %d,%d, want -1920,0", ox, oy)
142+ }
143+}
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 +317 -0
@@ -0,0 +1,317 @@
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.lx, r.ly, r.placed = int32(px), int32(py), true
91+ r.pend.x, r.pend.y = x.toX(r)
92+ x.markDirty(s, r)
93+ x.wake()
94+}
95+
96+// toX converts the layout position of r to X coordinates.
97+func (x *XWM) toX(r *record) (px, py int32) {
98+ ix, iy := x.space.ToX(int(r.lx), int(r.ly))
99+ return int32(ix), int32(iy)
100+}
101+
102+// SetLayout installs the output layout that X coordinates derive from. When it
103+// changes, placed managed windows are repositioned and mapped override windows
104+// are re-reported in layout coordinates. Display goroutine only.
105+func (x *XWM) SetLayout(l ports.Layout) {
106+ if x.closed {
107+ return
108+ }
109+ x.LayoutChanged(x.space.Set(l))
110+}
111+
112+// UseSpace makes sp the coordinate mapping, owned by the host, which calls
113+// Set on it and then LayoutChanged. Call before Attach.
114+func (x *XWM) UseSpace(sp *Space) { x.space = sp }
115+
116+// LayoutChanged repositions windows after the shared Space was set; changed
117+// is Set's result. Display goroutine only.
118+func (x *XWM) LayoutChanged(changed bool) {
119+ if x.closed {
120+ return
121+ }
122+ if n := x.space.Dropped(); n > 0 && !x.warnedSpace {
123+ x.warnedSpace = true
124+ x.log.Warn().Int("dropped", n).Int("max", maxRegions).Msg("more outputs than the X11 layout holds, extra outputs ignored")
125+ }
126+ if !changed {
127+ return
128+ }
129+ for i := range x.tab.recs {
130+ r, s := &x.tab.recs[i], Slot(i+1)
131+ switch {
132+ case r.kind == kindManaged && r.placed:
133+ r.pend.x, r.pend.y = x.toX(r)
134+ x.markDirty(s, r)
135+ case r.kind == kindUnmanaged && r.ovPaired:
136+ x.queueOverride(s, r)
137+ }
138+ }
139+ x.wake()
140+}
141+
142+// flushConfigures sends the pending configures, once per display iteration,
143+// then what focus left to do.
144+func (x *XWM) flushConfigures() {
145+ for _, s := range x.dirty {
146+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
147+ x.sendPending(s, r)
148+ }
149+ }
150+ x.dirty = x.dirty[:0]
151+ x.flushOverrides()
152+ x.flushFocus()
153+}
154+
155+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
156+// 4.1.5 asks for, since Xwayland may not move the window) and the
157+// _NET_WM_STATE bits that changed.
158+func (x *XWM) sendPending(s Slot, r *record) {
159+ r.pend.dirty = false
160+ p := &r.pend
161+ w, h := r.size()
162+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
163+ var v xproto.ConfigureWindowValues
164+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
165+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
166+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
167+ x.sendConfigureNotify(r)
168+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
169+ }
170+ var bits uint8
171+ if p.fullscreen {
172+ bits |= stateFullscreen
173+ }
174+ if p.activated {
175+ bits |= stateFocused
176+ }
177+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
178+ r.fullscreen = p.fullscreen // ours now: the property is not read back
179+ if bits != r.stateSent {
180+ r.stateSent = bits
181+ x.writeState(r)
182+ }
183+}
184+
185+// writeState rewrites _NET_WM_STATE of r from its bits.
186+func (x *XWM) writeState(r *record) {
187+ var list [3]uint32
188+ n := 0
189+ if r.modal {
190+ list[n] = x.atoms[atomNetWMStateModal]
191+ n++
192+ }
193+ if r.stateSent&stateFullscreen != 0 {
194+ list[n] = x.atoms[atomNetWMStateFullscreen]
195+ n++
196+ }
197+ if r.stateSent&stateFocused != 0 {
198+ list[n] = x.atoms[atomNetWMStateFocused]
199+ n++
200+ }
201+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
202+}
203+
204+// sendConfigureNotify tells the client its geometry as the XWM sees it.
205+func (x *XWM) sendConfigureNotify(r *record) {
206+ var raw [32]byte
207+ d := xproto.ConfigureNotifyEventData{
208+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
209+ X: int16(min(max(r.x, math.MinInt16), math.MaxInt16)), Y: int16(min(max(r.y, math.MinInt16), math.MaxInt16)),
210+ Width: uint16(min(r.w, math.MaxUint16)), Height: uint16(min(r.h, math.MaxUint16)),
211+ }
212+ d.Put(&raw)
213+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
214+}
215+
216+// onConfigureRequest answers a client resizing itself. Tiled windows are told
217+// their current geometry; floating ones get the size they ask for; unmanaged
218+// and unknown windows are configured as requested.
219+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
220+ e := xproto.ConfigureRequestOf(ev)
221+ xid := uint32(e.Window())
222+ mask := e.ValueMask()
223+ s := x.tab.lookup(xid)
224+ r := x.managedRec(s)
225+ if r == nil {
226+ var v xproto.ConfigureWindowValues
227+ if mask&xproto.ConfigWindowX != 0 {
228+ v.SetX(int32(e.X()))
229+ }
230+ if mask&xproto.ConfigWindowY != 0 {
231+ v.SetY(int32(e.Y()))
232+ }
233+ if mask&xproto.ConfigWindowWidth != 0 {
234+ v.SetWidth(uint32(e.Width()))
235+ }
236+ if mask&xproto.ConfigWindowHeight != 0 {
237+ v.SetHeight(uint32(e.Height()))
238+ }
239+ if mask&xproto.ConfigWindowBorderWidth != 0 {
240+ v.SetBorderWidth(uint32(e.BorderWidth()))
241+ }
242+ if mask&xproto.ConfigWindowSibling != 0 {
243+ v.SetSibling(e.Sibling())
244+ }
245+ if mask&xproto.ConfigWindowStackMode != 0 {
246+ v.SetStackMode(e.StackMode())
247+ }
248+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
249+ return
250+ }
251+ if !r.ready {
252+ x.configureBeforeReady(s, r, e)
253+ return
254+ }
255+ if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
256+ w, h := r.w, r.h
257+ if mask&xproto.ConfigWindowWidth != 0 {
258+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
259+ }
260+ if mask&xproto.ConfigWindowHeight != 0 {
261+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
262+ }
263+ if w > 0 && h > 0 {
264+ var v xproto.ConfigureWindowValues
265+ v.SetWidth(w).SetHeight(h)
266+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
267+ r.w, r.h = w, h
268+ // The size is the client's own: core learns it from the commit
269+ // and Configure only resends it when core wants another.
270+ r.pend.w, r.pend.h = w, h
271+ r.sent.w, r.sent.h = w, h
272+ }
273+ }
274+ x.sendConfigureNotify(r)
275+}
276+
277+// configureBeforeReady honours the geometry a window asks for before it is
278+// ready (XMoveResize before XMapWindow), floating or not: core only sees the
279+// window at Ready and the client must find the geometry it set.
280+func (x *XWM) configureBeforeReady(s Slot, r *record, e xproto.ConfigureRequestEvent) {
281+ mask := e.ValueMask()
282+ var v xproto.ConfigureWindowValues
283+ if mask&xproto.ConfigWindowX != 0 {
284+ r.x = int32(e.X())
285+ v.SetX(r.x)
286+ }
287+ if mask&xproto.ConfigWindowY != 0 {
288+ r.y = int32(e.Y())
289+ v.SetY(r.y)
290+ }
291+ if mask&xproto.ConfigWindowWidth != 0 {
292+ r.w, r.sized = uint32(min(max(int(e.Width()), 1), maxClientSize)), true
293+ v.SetWidth(r.w)
294+ }
295+ if mask&xproto.ConfigWindowHeight != 0 {
296+ r.h, r.sized = uint32(min(max(int(e.Height()), 1), maxClientSize)), true
297+ v.SetHeight(r.h)
298+ }
299+ if mask&(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
300+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
301+ }
302+ x.sendConfigureNotify(r)
303+}
304+
305+// onConfigureNotify keeps the known geometry of every top-level window.
306+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
307+ e := xproto.ConfigureNotifyOf(ev)
308+ if e.Event() != xproto.Window(x.root) {
309+ return
310+ }
311+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
312+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
313+ if r.kind == kindUnmanaged && r.ovPaired {
314+ x.queueOverride(x.tab.lookup(r.xid), r)
315+ }
316+ }
317+}
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 +260 -0
@@ -0,0 +1,260 @@
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+ kindOverrideType
24+)
25+
26+// onEvent dispatches an X event. Before Attach there is no host: only the
27+// table is maintained, and Attach replays the result.
28+func (x *XWM) onEvent(ev *xturbo.Event) {
29+ x.stats.Events++
30+ if x.closed {
31+ return
32+ }
33+ switch ev.Kind {
34+ case xproto.KindCreateNotify:
35+ x.onCreateNotify(ev)
36+ case xproto.KindDestroyNotify:
37+ x.onDestroyNotify(ev)
38+ case xproto.KindReparentNotify:
39+ x.onReparentNotify(ev)
40+ }
41+ if x.host == nil {
42+ x.onEventDetached(ev)
43+ return
44+ }
45+ switch ev.Kind {
46+ case xproto.KindMapRequest:
47+ x.onMapRequest(ev)
48+ case xproto.KindMapNotify:
49+ x.onMapNotify(ev)
50+ case xproto.KindUnmapNotify:
51+ x.onUnmapNotify(ev)
52+ case xproto.KindConfigureRequest:
53+ x.onConfigureRequest(ev)
54+ case xproto.KindConfigureNotify:
55+ x.onConfigureNotify(ev)
56+ case xproto.KindPropertyNotify:
57+ x.onPropertyNotify(ev)
58+ case xproto.KindClientMessage:
59+ x.onClientMessage(ev)
60+ case xproto.KindFocusIn:
61+ x.onFocusIn(ev)
62+ }
63+}
64+
65+// onEventDetached keeps what Attach replays: windows asked to map, and those
66+// withdrawn again, so Attach skips them; geometry requests are answered.
67+func (x *XWM) onEventDetached(ev *xturbo.Event) {
68+ switch ev.Kind {
69+ case xproto.KindMapRequest:
70+ if s := x.tab.lookup(uint32(xproto.MapRequestOf(ev).Window())); s != 0 {
71+ x.tab.rec(s).mapping = true
72+ }
73+ case xproto.KindUnmapNotify:
74+ if ev.SendEvent {
75+ return
76+ }
77+ if s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window())); s != 0 {
78+ r := x.tab.rec(s)
79+ r.mapped, r.mapping = false, false
80+ }
81+ case xproto.KindConfigureRequest:
82+ x.onConfigureRequest(ev)
83+ }
84+}
85+
86+func (x *XWM) onReply(r *xturbo.Reply) {
87+ x.stats.Replies++
88+ if x.closed {
89+ return
90+ }
91+ switch r.Kind {
92+ case kindVersion:
93+ x.onVersionReply(r)
94+ return
95+ case kindTree:
96+ x.onTreeReply(r)
97+ return
98+ }
99+ s, rec := x.tab.live(r.Args[0])
100+ if rec == nil {
101+ x.stats.Stale++
102+ return
103+ }
104+ switch r.Kind {
105+ case kindAttr:
106+ x.onAttrReply(rec, r)
107+ case kindProp:
108+ x.onPropReply(s, rec, r)
109+ case kindGeom:
110+ x.onGeomReply(s, rec, r)
111+ case kindClientIds:
112+ x.onClientIdsReply(s, rec, r)
113+ case kindOverrideType:
114+ x.onOverrideType(s, rec, r)
115+ }
116+}
117+
118+// onError never tears the XWM down: an error names a request, not the
119+// connection. Sticky Buf errors surface through Flush.
120+func (x *XWM) onError(e *xturbo.XError) {
121+ x.stats.Errors++
122+ if e.Kind == 0 {
123+ x.stats.Unattributed++
124+ x.log.Debug().Uint8("code", e.Code).Uint8("major", e.Major).Uint16("minor", e.Minor).
125+ Uint64("seq", uint64(e.Seq)).Msg("unattributed X error")
126+ return
127+ }
128+ switch e.Kind {
129+ case kindRootMask:
130+ if e.Code == xproto.BadAccess {
131+ x.bootErr = ErrWMExists
132+ }
133+ case kindProp, kindGeom, kindClientIds, kindConfigure, kindFocus, kindOverrideType:
134+ // Usually BadWindow: the window died between request and reply and
135+ // DestroyNotify follows. Whatever the code, the awaited reply will
136+ // never come, so the map must not wait for it.
137+ s, rec := x.tab.live(e.Args[0])
138+ if rec == nil {
139+ x.stats.Stale++
140+ return
141+ }
142+ switch e.Kind {
143+ case kindProp:
144+ rec.pending &^= 1 << e.Args[1]
145+ case kindGeom:
146+ rec.pending &^= propGeom
147+ case kindClientIds:
148+ rec.pending &^= propClientIDs
149+ case kindOverrideType:
150+ if e.Code != xproto.BadWindow { // typed unknown: the window still shows
151+ x.overrideReady(s)
152+ }
153+ return
154+ default:
155+ return
156+ }
157+ if e.Code != xproto.BadWindow && e.Code != xproto.BadDrawable { // a dead window is never announced
158+ x.checkReady(s)
159+ }
160+ }
161+}
162+
163+// onCreateNotify records a new top-level window. Override-redirect windows
164+// are kept (their geometry and serial matter later) but never reach the host.
165+func (x *XWM) onCreateNotify(ev *xturbo.Event) {
166+ e := xproto.CreateNotifyOf(ev)
167+ xid := uint32(e.Window())
168+ if xid == x.wmWin || x.tab.lookup(xid) != 0 {
169+ return
170+ }
171+ s := x.insert(xid)
172+ if s == 0 {
173+ return
174+ }
175+ r := x.tab.rec(s)
176+ override := e.OverrideRedirect()
177+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
178+ if override {
179+ r.kind = kindUnmanaged
180+ x.stats.UnmanagedSeen++
181+ return
182+ }
183+ if x.host != nil {
184+ x.adopt(s)
185+ }
186+}
187+
188+// adopt announces a managed window to the host and starts watching it.
189+func (x *XWM) adopt(s Slot) {
190+ r := x.tab.rec(s)
191+ r.id = x.host.Created(s, r.xid)
192+ var v xproto.ChangeWindowAttributesValues
193+ v.SetEventMask(xproto.EventMaskPropertyChange | xproto.EventMaskFocusChange)
194+ x.expect(xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(r.xid), &v), kindAttr, x.tab.tag(s), 0)
195+ x.log.Debug().Uint32("xid", r.xid).Uint32("slot", uint32(s)).Msg("x11 window created")
196+}
197+
198+func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
199+ xid := uint32(xproto.DestroyNotifyOf(ev).Window())
200+ s := x.tab.lookup(xid)
201+ if s == 0 {
202+ return
203+ }
204+ x.forgetWindow(s)
205+}
206+
207+// onTreeReply (boot): record every existing child and ask for its attributes.
208+func (x *XWM) onTreeReply(r *xturbo.Reply) {
209+ q, ok := xproto.ParseQueryTreeReply(r.Raw)
210+ if !ok {
211+ return
212+ }
213+ children := q.Children()
214+ for i := 0; i < children.Len(); i++ {
215+ xid := uint32(children.At(i))
216+ if xid == x.wmWin || x.tab.lookup(xid) != 0 {
217+ continue
218+ }
219+ s := x.insert(xid)
220+ if s == 0 {
221+ return
222+ }
223+ x.expect(xproto.GetWindowAttributes(x.conn.Buf(), xproto.Window(xid)), kindAttr, x.tab.tag(s), 0)
224+ }
225+}
226+
227+// onAttrReply (boot): classify an existing window; viewable ones go through
228+// the map flow once the host is attached.
229+func (x *XWM) onAttrReply(rec *record, r *xturbo.Reply) {
230+ a, ok := xproto.ParseGetWindowAttributesReply(r.Raw)
231+ if !ok || x.host != nil {
232+ return
233+ }
234+ if a.OverrideRedirect() {
235+ rec.kind = kindUnmanaged
236+ rec.mapped = a.MapState() == xproto.MapStateViewable
237+ x.stats.UnmanagedSeen++
238+ return
239+ }
240+ if a.MapState() == xproto.MapStateViewable {
241+ rec.mapped, rec.mapping = true, true
242+ }
243+}
244+
245+func (x *XWM) onVersionReply(r *xturbo.Reply) {
246+ switch r.Args[0] {
247+ case versionComposite:
248+ if v, ok := composite.ParseQueryVersionReply(r.Raw); ok {
249+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("Composite")
250+ }
251+ case versionXFixes:
252+ if v, ok := xfixes.ParseQueryVersionReply(r.Raw); ok {
253+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("XFixes")
254+ }
255+ case versionRes:
256+ if v, ok := res.ParseQueryVersionReply(r.Raw); ok {
257+ x.log.Info().Uint16("major", v.ServerMajor()).Uint16("minor", v.ServerMinor()).Msg("X-Resource")
258+ }
259+ }
260+}
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 +218 -0
@@ -0,0 +1,218 @@
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+ // Keyboard grabs are how menus steal focus legitimately (wlroots does
25+ // the same): fighting them would close every Steam menu.
26+ if e.Mode() == xproto.NotifyModeGrab || e.Mode() == xproto.NotifyModeUngrab {
27+ return
28+ }
29+ s := x.tab.lookup(uint32(e.Event()))
30+ if s == 0 || s == x.active {
31+ return
32+ }
33+ if r := x.tab.rec(s); r.kind == kindUnmanaged {
34+ return
35+ }
36+ x.refocus = true
37+ x.wake()
38+}
39+
40+// Focus gives keyboard focus to s; 0 means a native Wayland window or none.
41+// Focus never uses PointerRoot: without an X window to focus it is None.
42+func (x *XWM) Focus(s Slot) {
43+ if x.closed {
44+ return
45+ }
46+ r := x.managedRec(s)
47+ if r == nil {
48+ s = 0
49+ }
50+ if s == x.active {
51+ return
52+ }
53+ if old := x.managedRec(x.active); old != nil {
54+ old.pend.activated = false
55+ x.markDirty(x.active, old)
56+ }
57+ x.active = s
58+ if r == nil {
59+ x.issueFocus(0, nil)
60+ x.setActive(0)
61+ x.wake()
62+ return
63+ }
64+ r.pend.activated = true
65+ x.markDirty(s, r)
66+ x.issueFocus(s, r)
67+ x.setActive(r.xid)
68+ x.raise(r)
69+ x.wake()
70+}
71+
72+// issueFocus applies the ICCCM input model of r: Passive and Locally active
73+// windows get SetInputFocus, Locally and Globally active ones WM_TAKE_FOCUS,
74+// No-input windows nothing. A nil r clears the X focus.
75+func (x *XWM) issueFocus(s Slot, r *record) {
76+ b := x.conn.Buf()
77+ if r == nil {
78+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
79+ return
80+ }
81+ if r.input {
82+ x.expect(xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.Window(r.xid), 0), kindFocus, x.tab.tag(s), 0)
83+ }
84+ if r.takeFocus {
85+ x.sendProtocol(r.xid, atomWMTakeFocus)
86+ }
87+}
88+
89+// sendProtocol sends a WM_PROTOCOLS client message [proto, CurrentTime].
90+func (x *XWM) sendProtocol(xid uint32, proto int) {
91+ var raw [32]byte
92+ raw[0], raw[1] = 33, 32 // ClientMessage, format 32
93+ binary.LittleEndian.PutUint32(raw[4:], xid)
94+ binary.LittleEndian.PutUint32(raw[8:], x.atoms[atomWMProtocols])
95+ binary.LittleEndian.PutUint32(raw[12:], x.atoms[proto])
96+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(xid), xproto.EventMaskNoEvent, &raw)
97+}
98+
99+// setActive writes _NET_ACTIVE_WINDOW on the root.
100+func (x *XWM) setActive(xid uint32) {
101+ v := [1]uint32{xid}
102+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, v[:])
103+}
104+
105+// raise restacks r above its siblings and at the top of the stacking list.
106+func (x *XWM) raise(r *record) {
107+ var v xproto.ConfigureWindowValues
108+ v.SetStackMode(xproto.StackModeAbove)
109+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v)
110+ if i := slices.Index(x.stacking, r.xid); i >= 0 {
111+ x.stacking = append(x.stacking[:i], x.stacking[i+1:]...)
112+ x.stacking = append(x.stacking, r.xid)
113+ x.listsDirty |= listStacking
114+ }
115+}
116+
117+// CloseWindow asks s to close (WM_DELETE_WINDOW), or kills its client.
118+func (x *XWM) CloseWindow(s Slot) {
119+ r := x.managedRec(s)
120+ if r == nil {
121+ return
122+ }
123+ if r.deleteWindow {
124+ x.sendProtocol(r.xid, atomWMDeleteWindow)
125+ } else {
126+ xproto.KillClient(x.conn.Buf(), r.xid)
127+ }
128+ x.wake()
129+}
130+
131+// addClient puts a ready window at the end of both client lists.
132+func (x *XWM) addClient(xid uint32) {
133+ if !slices.Contains(x.clients, xid) {
134+ x.clients = append(x.clients, xid)
135+ x.listsDirty |= listClients
136+ }
137+ if !slices.Contains(x.stacking, xid) {
138+ x.stacking = append(x.stacking, xid)
139+ x.listsDirty |= listStacking
140+ }
141+ x.wake()
142+}
143+
144+// removeClient drops an unmapped or destroyed window from both client lists.
145+func (x *XWM) removeClient(xid uint32) {
146+ if i := slices.Index(x.clients, xid); i >= 0 {
147+ x.clients = slices.Delete(x.clients, i, i+1)
148+ x.listsDirty |= listClients
149+ }
150+ if i := slices.Index(x.stacking, xid); i >= 0 {
151+ x.stacking = slices.Delete(x.stacking, i, i+1)
152+ x.listsDirty |= listStacking
153+ }
154+ x.wake()
155+}
156+
157+// dropWindow takes s out of the client lists, the focus and the configure
158+// queue when it stops being mapped (unmap, destroy). It is idempotent.
159+func (x *XWM) dropWindow(s Slot) {
160+ r := x.tab.rec(s)
161+ if r != nil {
162+ r.ovQueued = false // a reused slot must not inherit a queued report
163+ }
164+ if r == nil || r.kind != kindManaged {
165+ return
166+ }
167+ x.removeClient(r.xid)
168+ r.pend.dirty = false // the queued slot is skipped at flush
169+ // A remap starts from a clean _NET_WM_STATE, so Focus writes FOCUSED again.
170+ r.pend.activated, r.sent.activated = false, false
171+ r.sent.fullscreen, r.stateSent = false, 0
172+ if x.active == s {
173+ x.active = 0
174+ x.refocus = false
175+ x.setActive(0)
176+ }
177+}
178+
179+// forgetWindow ends the life of s (destroy, reparent away): it is dropped,
180+// the host is told if it knew the window, and the record is freed.
181+func (x *XWM) forgetWindow(s Slot) {
182+ r := x.tab.rec(s)
183+ if r == nil || r.kind == kindFree {
184+ return
185+ }
186+ x.dropWindow(s)
187+ if r.kind == kindUnmanaged && r.ovPaired && x.host != nil {
188+ r.ovPaired = false
189+ x.host.OverrideGone(s)
190+ }
191+ if r.kind == kindManaged && r.id != 0 && x.host != nil {
192+ r.id = 0
193+ x.host.Dissociate(s, true)
194+ if x.active == s { // the host re-focused the window while dissociating it
195+ x.active, x.refocus = 0, false
196+ x.issueFocus(0, nil)
197+ x.setActive(0)
198+ x.wake()
199+ }
200+ }
201+ x.tab.remove(s)
202+}
203+
204+// flushFocus rewrites the client lists that changed and re-issues a denied
205+// focus, once per display iteration.
206+func (x *XWM) flushFocus() {
207+ if x.listsDirty&listClients != 0 {
208+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, x.clients)
209+ }
210+ if x.listsDirty&listStacking != 0 {
211+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, x.stacking)
212+ }
213+ x.listsDirty = 0
214+ if x.refocus {
215+ x.refocus = false
216+ x.issueFocus(x.active, x.managedRec(x.active))
217+ }
218+}
A internal/adapters/wayland/xwm/host.go +57 -0
@@ -0,0 +1,57 @@
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+ // OverrideMapped reports an override-redirect window that is mapped,
45+ // typed and paired with its wl_surface. Rect is in layout coordinates.
46+ OverrideMapped(slot Slot, xid uint32, surfaceID uint64, x, y, w, h int, noInput bool)
47+ // OverrideMoved reports a changed layout rect of a mapped override window.
48+ OverrideMoved(slot Slot, x, y, w, h int)
49+ // OverrideGone ends a mapped override window: unmapped or destroyed.
50+ OverrideGone(slot Slot)
51+}
52+
53+// fdSource is the display's event source for the X socket (*server.Source).
54+type fdSource interface {
55+ SetWritable(on bool) error
56+ Remove()
57+}
A internal/adapters/wayland/xwm/host_mock_test.go +611 -0
@@ -0,0 +1,611 @@
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+// OverrideGone provides a mock function for the type mockhost
290+func (_mock *mockhost) OverrideGone(slot Slot) {
291+ _mock.Called(slot)
292+ return
293+}
294+
295+// mockhost_OverrideGone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideGone'
296+type mockhost_OverrideGone_Call struct {
297+ *mock.Call
298+}
299+
300+// OverrideGone is a helper method to define mock.On call
301+// - slot Slot
302+func (_e *mockhost_Expecter) OverrideGone(slot any) *mockhost_OverrideGone_Call {
303+ return &mockhost_OverrideGone_Call{Call: _e.mock.On("OverrideGone", slot)}
304+}
305+
306+func (_c *mockhost_OverrideGone_Call) Run(run func(slot Slot)) *mockhost_OverrideGone_Call {
307+ _c.Call.Run(func(args mock.Arguments) {
308+ var arg0 Slot
309+ if args[0] != nil {
310+ arg0 = args[0].(Slot)
311+ }
312+ run(
313+ arg0,
314+ )
315+ })
316+ return _c
317+}
318+
319+func (_c *mockhost_OverrideGone_Call) Return() *mockhost_OverrideGone_Call {
320+ _c.Call.Return()
321+ return _c
322+}
323+
324+func (_c *mockhost_OverrideGone_Call) RunAndReturn(run func(slot Slot)) *mockhost_OverrideGone_Call {
325+ _c.Run(run)
326+ return _c
327+}
328+
329+// OverrideMapped provides a mock function for the type mockhost
330+func (_mock *mockhost) OverrideMapped(slot Slot, xid uint32, surfaceID uint64, x int, y int, w int, h int, noInput bool) {
331+ _mock.Called(slot, xid, surfaceID, x, y, w, h, noInput)
332+ return
333+}
334+
335+// mockhost_OverrideMapped_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideMapped'
336+type mockhost_OverrideMapped_Call struct {
337+ *mock.Call
338+}
339+
340+// OverrideMapped is a helper method to define mock.On call
341+// - slot Slot
342+// - xid uint32
343+// - surfaceID uint64
344+// - x int
345+// - y int
346+// - w int
347+// - h int
348+// - noInput bool
349+func (_e *mockhost_Expecter) OverrideMapped(slot any, xid any, surfaceID any, x any, y any, w any, h any, noInput any) *mockhost_OverrideMapped_Call {
350+ return &mockhost_OverrideMapped_Call{Call: _e.mock.On("OverrideMapped", slot, xid, surfaceID, x, y, w, h, noInput)}
351+}
352+
353+func (_c *mockhost_OverrideMapped_Call) Run(run func(slot Slot, xid uint32, surfaceID uint64, x int, y int, w int, h int, noInput bool)) *mockhost_OverrideMapped_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 uint32
360+ if args[1] != nil {
361+ arg1 = args[1].(uint32)
362+ }
363+ var arg2 uint64
364+ if args[2] != nil {
365+ arg2 = args[2].(uint64)
366+ }
367+ var arg3 int
368+ if args[3] != nil {
369+ arg3 = args[3].(int)
370+ }
371+ var arg4 int
372+ if args[4] != nil {
373+ arg4 = args[4].(int)
374+ }
375+ var arg5 int
376+ if args[5] != nil {
377+ arg5 = args[5].(int)
378+ }
379+ var arg6 int
380+ if args[6] != nil {
381+ arg6 = args[6].(int)
382+ }
383+ var arg7 bool
384+ if args[7] != nil {
385+ arg7 = args[7].(bool)
386+ }
387+ run(
388+ arg0,
389+ arg1,
390+ arg2,
391+ arg3,
392+ arg4,
393+ arg5,
394+ arg6,
395+ arg7,
396+ )
397+ })
398+ return _c
399+}
400+
401+func (_c *mockhost_OverrideMapped_Call) Return() *mockhost_OverrideMapped_Call {
402+ _c.Call.Return()
403+ return _c
404+}
405+
406+func (_c *mockhost_OverrideMapped_Call) RunAndReturn(run func(slot Slot, xid uint32, surfaceID uint64, x int, y int, w int, h int, noInput bool)) *mockhost_OverrideMapped_Call {
407+ _c.Run(run)
408+ return _c
409+}
410+
411+// OverrideMoved provides a mock function for the type mockhost
412+func (_mock *mockhost) OverrideMoved(slot Slot, x int, y int, w int, h int) {
413+ _mock.Called(slot, x, y, w, h)
414+ return
415+}
416+
417+// mockhost_OverrideMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideMoved'
418+type mockhost_OverrideMoved_Call struct {
419+ *mock.Call
420+}
421+
422+// OverrideMoved is a helper method to define mock.On call
423+// - slot Slot
424+// - x int
425+// - y int
426+// - w int
427+// - h int
428+func (_e *mockhost_Expecter) OverrideMoved(slot any, x any, y any, w any, h any) *mockhost_OverrideMoved_Call {
429+ return &mockhost_OverrideMoved_Call{Call: _e.mock.On("OverrideMoved", slot, x, y, w, h)}
430+}
431+
432+func (_c *mockhost_OverrideMoved_Call) Run(run func(slot Slot, x int, y int, w int, h int)) *mockhost_OverrideMoved_Call {
433+ _c.Call.Run(func(args mock.Arguments) {
434+ var arg0 Slot
435+ if args[0] != nil {
436+ arg0 = args[0].(Slot)
437+ }
438+ var arg1 int
439+ if args[1] != nil {
440+ arg1 = args[1].(int)
441+ }
442+ var arg2 int
443+ if args[2] != nil {
444+ arg2 = args[2].(int)
445+ }
446+ var arg3 int
447+ if args[3] != nil {
448+ arg3 = args[3].(int)
449+ }
450+ var arg4 int
451+ if args[4] != nil {
452+ arg4 = args[4].(int)
453+ }
454+ run(
455+ arg0,
456+ arg1,
457+ arg2,
458+ arg3,
459+ arg4,
460+ )
461+ })
462+ return _c
463+}
464+
465+func (_c *mockhost_OverrideMoved_Call) Return() *mockhost_OverrideMoved_Call {
466+ _c.Call.Return()
467+ return _c
468+}
469+
470+func (_c *mockhost_OverrideMoved_Call) RunAndReturn(run func(slot Slot, x int, y int, w int, h int)) *mockhost_OverrideMoved_Call {
471+ _c.Run(run)
472+ return _c
473+}
474+
475+// Parent provides a mock function for the type mockhost
476+func (_mock *mockhost) Parent(slot Slot, parent Slot) {
477+ _mock.Called(slot, parent)
478+ return
479+}
480+
481+// mockhost_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent'
482+type mockhost_Parent_Call struct {
483+ *mock.Call
484+}
485+
486+// Parent is a helper method to define mock.On call
487+// - slot Slot
488+// - parent Slot
489+func (_e *mockhost_Expecter) Parent(slot any, parent any) *mockhost_Parent_Call {
490+ return &mockhost_Parent_Call{Call: _e.mock.On("Parent", slot, parent)}
491+}
492+
493+func (_c *mockhost_Parent_Call) Run(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
494+ _c.Call.Run(func(args mock.Arguments) {
495+ var arg0 Slot
496+ if args[0] != nil {
497+ arg0 = args[0].(Slot)
498+ }
499+ var arg1 Slot
500+ if args[1] != nil {
501+ arg1 = args[1].(Slot)
502+ }
503+ run(
504+ arg0,
505+ arg1,
506+ )
507+ })
508+ return _c
509+}
510+
511+func (_c *mockhost_Parent_Call) Return() *mockhost_Parent_Call {
512+ _c.Call.Return()
513+ return _c
514+}
515+
516+func (_c *mockhost_Parent_Call) RunAndReturn(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
517+ _c.Run(run)
518+ return _c
519+}
520+
521+// Ready provides a mock function for the type mockhost
522+func (_mock *mockhost) Ready(slot Slot, info WindowInfo) {
523+ _mock.Called(slot, info)
524+ return
525+}
526+
527+// mockhost_Ready_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ready'
528+type mockhost_Ready_Call struct {
529+ *mock.Call
530+}
531+
532+// Ready is a helper method to define mock.On call
533+// - slot Slot
534+// - info WindowInfo
535+func (_e *mockhost_Expecter) Ready(slot any, info any) *mockhost_Ready_Call {
536+ return &mockhost_Ready_Call{Call: _e.mock.On("Ready", slot, info)}
537+}
538+
539+func (_c *mockhost_Ready_Call) Run(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
540+ _c.Call.Run(func(args mock.Arguments) {
541+ var arg0 Slot
542+ if args[0] != nil {
543+ arg0 = args[0].(Slot)
544+ }
545+ var arg1 WindowInfo
546+ if args[1] != nil {
547+ arg1 = args[1].(WindowInfo)
548+ }
549+ run(
550+ arg0,
551+ arg1,
552+ )
553+ })
554+ return _c
555+}
556+
557+func (_c *mockhost_Ready_Call) Return() *mockhost_Ready_Call {
558+ _c.Call.Return()
559+ return _c
560+}
561+
562+func (_c *mockhost_Ready_Call) RunAndReturn(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
563+ _c.Run(run)
564+ return _c
565+}
566+
567+// Title provides a mock function for the type mockhost
568+func (_mock *mockhost) Title(slot Slot, title string) {
569+ _mock.Called(slot, title)
570+ return
571+}
572+
573+// mockhost_Title_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Title'
574+type mockhost_Title_Call struct {
575+ *mock.Call
576+}
577+
578+// Title is a helper method to define mock.On call
579+// - slot Slot
580+// - title string
581+func (_e *mockhost_Expecter) Title(slot any, title any) *mockhost_Title_Call {
582+ return &mockhost_Title_Call{Call: _e.mock.On("Title", slot, title)}
583+}
584+
585+func (_c *mockhost_Title_Call) Run(run func(slot Slot, title string)) *mockhost_Title_Call {
586+ _c.Call.Run(func(args mock.Arguments) {
587+ var arg0 Slot
588+ if args[0] != nil {
589+ arg0 = args[0].(Slot)
590+ }
591+ var arg1 string
592+ if args[1] != nil {
593+ arg1 = args[1].(string)
594+ }
595+ run(
596+ arg0,
597+ arg1,
598+ )
599+ })
600+ return _c
601+}
602+
603+func (_c *mockhost_Title_Call) Return() *mockhost_Title_Call {
604+ _c.Call.Return()
605+ return _c
606+}
607+
608+func (_c *mockhost_Title_Call) RunAndReturn(run func(slot Slot, title string)) *mockhost_Title_Call {
609+ _c.Run(run)
610+ return _c
611+}
A internal/adapters/wayland/xwm/mapflow.go +355 -0
@@ -0,0 +1,355 @@
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+ s := x.tab.lookup(uint32(xproto.MapNotifyOf(ev).Window()))
49+ if s == 0 {
50+ return
51+ }
52+ rec := x.tab.rec(s)
53+ again := rec.mapped
54+ rec.mapped = true
55+ if rec.kind == kindUnmanaged && !again {
56+ x.requestOverrideType(s)
57+ }
58+}
59+
60+// onUnmapNotify ends the mapped life of a managed window; a later MapRequest
61+// starts over and re-reads the properties. Synthetic events (ICCCM unmap
62+// requests) are not an unmap.
63+func (x *XWM) onUnmapNotify(ev *xturbo.Event) {
64+ if ev.SendEvent {
65+ return
66+ }
67+ s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window()))
68+ if s == 0 {
69+ return
70+ }
71+ rec := x.tab.rec(s)
72+ rec.mapped = false
73+ if rec.kind == kindUnmanaged {
74+ x.overrideUnmapped(s, rec)
75+ return
76+ }
77+ if rec.kind != kindManaged {
78+ return
79+ }
80+ wasMapping := rec.mapping
81+ rec.ready, rec.mapping, rec.pending, rec.sized = false, false, 0, false
82+ rec.gen++ // replies still in flight for this map name a dead tag
83+ if rec.gen == 0 {
84+ rec.gen = 1
85+ }
86+ rec.pend.dirty = false
87+ rec.serial, rec.surface = 0, 0
88+ if wasMapping {
89+ xid := rec.xid
90+ x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateWithdrawn, 0})
91+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(xid), xproto.Atom(x.atoms[atomNetWMState]))
92+ }
93+ x.dropWindow(s)
94+ if rec.id != 0 {
95+ x.host.Dissociate(s, false)
96+ }
97+}
98+
99+// setProp32Typed is setProp32 with an explicit property type atom.
100+func (x *XWM) setProp32Typed(win uint32, prop int, typ uint32, vals []uint32) {
101+ x.setProp32(win, prop, xproto.Atom(typ), vals)
102+}
103+
104+// checkReady fires Ready once every awaited reply is in.
105+func (x *XWM) checkReady(s Slot) {
106+ rec := x.tab.rec(s)
107+ if rec == nil || rec.pending != 0 || rec.ready || !rec.mapping || rec.kind != kindManaged || x.host == nil {
108+ return
109+ }
110+ rec.ready = true
111+ x.classify(rec)
112+ info := x.info(s)
113+ xid := rec.xid
114+
115+ var state [2]uint32
116+ n := 0
117+ if rec.modal {
118+ state[n] = x.atoms[atomNetWMStateModal]
119+ n++
120+ }
121+ if rec.fullscreen {
122+ state[n] = x.atoms[atomNetWMStateFullscreen]
123+ n++
124+ }
125+ x.setProp32(xid, atomNetWMState, xproto.AtomAtom, state[:n])
126+ x.setProp32Typed(xid, atomWMState, x.atoms[atomWMState], []uint32{wmStateNormal, 0})
127+ x.addClient(xid)
128+
129+ x.host.Ready(s, info)
130+ x.tryPair(s)
131+}
132+
133+// info is what the host learns when a window is ready.
134+func (x *XWM) info(s Slot) WindowInfo {
135+ rec := x.tab.rec(s)
136+ parent := x.managedSlot(rec.transient)
137+ info := WindowInfo{
138+ PID: int(rec.pid),
139+ Floating: rec.floating,
140+ Parent: parent,
141+ Modal: rec.modal,
142+ Fullscreen: rec.fullscreen,
143+ Title: rec.title,
144+ AppID: rec.class,
145+ }
146+ if info.Floating || rec.sized {
147+ info.Width, info.Height = int(rec.w), int(rec.h)
148+ }
149+ return info
150+}
151+
152+func (x *XWM) onGeomReply(s Slot, rec *record, r *xturbo.Reply) {
153+ if g, ok := xproto.ParseGetGeometryReply(r.Raw); ok {
154+ rec.x, rec.y, rec.w, rec.h = int32(g.X()), int32(g.Y()), uint32(g.Width()), uint32(g.Height())
155+ }
156+ rec.pending &^= propGeom
157+ x.checkReady(s)
158+}
159+
160+// onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy
161+// WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.
162+func (x *XWM) onClientMessage(ev *xturbo.Event) {
163+ e := xproto.ClientMessageOf(ev)
164+ if e.Format() != 32 {
165+ return
166+ }
167+ typ := uint32(e.Type())
168+ switch typ {
169+ case x.atoms[atomWLSurfaceSerial]:
170+ s := x.tab.lookup(uint32(e.Window()))
171+ if s == 0 {
172+ return
173+ }
174+ rec := x.tab.rec(s)
175+ if rec.serial != 0 {
176+ x.log.Debug().Uint32("xid", rec.xid).Msg("duplicate WL_SURFACE_SERIAL ignored")
177+ return
178+ }
179+ d := e.Data().Data32()
180+ rec.serial = uint64(d[1])<<32 | uint64(d[0])
181+ x.tryPair(s)
182+ case x.atoms[atomWLSurfaceID]:
183+ x.stats.LegacyID++
184+ if x.stats.LegacyID == 1 {
185+ x.log.Warn().Msg("WL_SURFACE_ID received: Xwayland did not bind xwayland_shell_v1, window ignored")
186+ }
187+ case x.atoms[atomNetWMState]:
188+ x.onNetWMState(e)
189+ case x.atoms[atomWMChangeState]:
190+ // Iconify requests are ignored: windows stay mapped.
191+ }
192+}
193+
194+// onNetWMState forwards fullscreen requests to the host and records them; the
195+// property itself is written when core answers with a configure.
196+func (x *XWM) onNetWMState(e xproto.ClientMessageEvent) {
197+ s := x.tab.lookup(uint32(e.Window()))
198+ if s == 0 {
199+ return
200+ }
201+ rec := x.tab.rec(s)
202+ if rec.kind != kindManaged {
203+ return
204+ }
205+ d := e.Data().Data32()
206+ apply := func(cur bool) bool {
207+ switch d[0] {
208+ case netStateRemove:
209+ return false
210+ case netStateAdd:
211+ return true
212+ case netStateToggle:
213+ return !cur
214+ }
215+ return cur
216+ }
217+ for _, a := range d[1:3] {
218+ switch {
219+ case a == 0:
220+ case a == x.atoms[atomNetWMStateModal]:
221+ rec.modal = apply(rec.modal)
222+ case a == x.atoms[atomNetWMStateFullscreen]:
223+ on := apply(rec.fullscreen)
224+ if on == rec.fullscreen {
225+ continue
226+ }
227+ rec.fullscreen = on
228+ if rec.ready {
229+ x.host.Fullscreen(s, on)
230+ rec = x.tab.rec(s)
231+ }
232+ }
233+ }
234+}
235+
236+// onReparentNotify keeps the table to root's direct children: a window moved
237+// away is forgotten, one moved into root is treated as newly created.
238+func (x *XWM) onReparentNotify(ev *xturbo.Event) {
239+ e := xproto.ReparentNotifyOf(ev)
240+ xid := uint32(e.Window())
241+ if xid == x.wmWin {
242+ return
243+ }
244+ s := x.tab.lookup(xid)
245+ if uint32(e.Parent()) != x.root {
246+ if s != 0 {
247+ x.forgetWindow(s)
248+ }
249+ return
250+ }
251+ if s != 0 {
252+ return
253+ }
254+ s = x.insert(xid)
255+ if s == 0 {
256+ return
257+ }
258+ rec := x.tab.rec(s)
259+ rec.x, rec.y = int32(e.X()), int32(e.Y())
260+ if e.OverrideRedirect() {
261+ rec.kind = kindUnmanaged
262+ x.stats.UnmanagedSeen++
263+ return
264+ }
265+ if x.host != nil {
266+ x.adopt(s)
267+ }
268+}
269+
270+// tryPair links the window to the wl_surface carrying its serial, once both
271+// are known and the window is ready (the host maps on the surface's first
272+// buffer, so it must know the window's info by then). Unmanaged windows keep
273+// the surface but are never associated.
274+func (x *XWM) tryPair(s Slot) {
275+ rec := x.tab.rec(s)
276+ if rec.serial == 0 || rec.surface != 0 {
277+ return
278+ }
279+ if rec.kind == kindManaged && !rec.ready {
280+ return
281+ }
282+ for i := 0; i < x.unpairedN; i++ {
283+ if x.unpaired[i].serial != rec.serial {
284+ continue
285+ }
286+ rec.surface = x.unpaired[i].surface
287+ x.unpairedN--
288+ x.unpaired[i] = x.unpaired[x.unpairedN]
289+ if rec.kind == kindManaged {
290+ x.log.Debug().Uint32("xid", rec.xid).Uint64("surface", rec.surface).Msg("x11 window paired")
291+ x.host.Associate(s, rec.surface)
292+ } else {
293+ x.overrideMapped(s, rec)
294+ }
295+ return
296+ }
297+}
298+
299+// SetSerial records the serial of a wl_surface (xwayland_surface_v1.set_serial)
300+// and pairs it with the window announcing the same serial.
301+func (x *XWM) SetSerial(surfaceID uint64, serial uint64) {
302+ if serial == 0 || x.closed {
303+ return
304+ }
305+ for i := 0; i < x.unpairedN; i++ {
306+ if x.unpaired[i].serial == serial {
307+ x.unpaired[i].surface = surfaceID
308+ x.pairSerial(serial)
309+ return
310+ }
311+ }
312+ if x.unpairedN == unpairedCap {
313+ copy(x.unpaired[:], x.unpaired[1:])
314+ x.unpairedN--
315+ if !x.warnedUnpaired {
316+ x.warnedUnpaired = true
317+ x.log.Warn().Msg("too many unpaired surfaces, dropping the oldest")
318+ }
319+ }
320+ x.unpaired[x.unpairedN] = struct{ serial, surface uint64 }{serial, surfaceID}
321+ x.unpairedN++
322+ x.pairSerial(serial)
323+}
324+
325+// pairSerial offers a newly known surface serial to the windows.
326+func (x *XWM) pairSerial(serial uint64) {
327+ for i := range x.tab.recs {
328+ if r := &x.tab.recs[i]; r.kind != kindFree && r.serial == serial && r.surface == 0 {
329+ x.tryPair(Slot(i + 1))
330+ break
331+ }
332+ }
333+ x.wake()
334+}
335+
336+// SurfaceGone forgets a destroyed wl_surface.
337+func (x *XWM) SurfaceGone(surfaceID uint64) {
338+ for i := 0; i < x.unpairedN; i++ {
339+ if x.unpaired[i].surface == surfaceID {
340+ x.unpairedN--
341+ x.unpaired[i] = x.unpaired[x.unpairedN]
342+ break
343+ }
344+ }
345+ for i := range x.tab.recs {
346+ if r := &x.tab.recs[i]; r.kind != kindFree && r.surface == surfaceID {
347+ r.surface = 0
348+ if r.kind == kindUnmanaged && r.ovPaired {
349+ r.ovPaired = false
350+ x.host.OverrideGone(Slot(i + 1))
351+ }
352+ }
353+ }
354+ x.wake()
355+}
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/space.go +135 -0
@@ -0,0 +1,135 @@
1+package xwm
2+
3+import "github.com/bnema/neferwl/internal/ports"
4+
5+const maxRegions = 16
6+
7+// Region is one output's part of the layout and where it is in X space.
8+// X = layout - Origin.
9+type Region struct {
10+ Layout, X ports.Rect // logical layout rect; X rect in X pixels
11+}
12+
13+// Space maps layout coordinates (which may be negative) to X coordinates
14+// (non-negative: the layout top-left is X (0,0)). Fixed storage, no allocs.
15+type Space struct {
16+ regions [maxRegions]Region
17+ n int
18+ dropped int // outputs beyond maxRegions in the last Set
19+ ox, oy int // layout point that is X (0,0)
20+ w, h int // X screen size: the extents
21+}
22+
23+// Origin is the layout point that is X (0,0): the top-left of the layout
24+// extents.
25+func (s *Space) Origin() (ox, oy int) { return s.ox, s.oy }
26+
27+// Dropped is how many outputs of the last Set did not fit.
28+func (s *Space) Dropped() int { return s.dropped }
29+
30+// Set recomputes the mapping and reports whether it changed.
31+func (s *Space) Set(l ports.Layout) bool {
32+ var next Space
33+ n := len(l)
34+ if n > maxRegions {
35+ next.dropped = n - maxRegions
36+ n = maxRegions
37+ }
38+ next.n = n
39+ var maxX, maxY int
40+ for i := 0; i < n; i++ {
41+ o := l[i]
42+ if i == 0 || o.X < next.ox {
43+ next.ox = o.X
44+ }
45+ if i == 0 || o.Y < next.oy {
46+ next.oy = o.Y
47+ }
48+ if i == 0 || o.X+o.Width > maxX {
49+ maxX = o.X + o.Width
50+ }
51+ if i == 0 || o.Y+o.Height > maxY {
52+ maxY = o.Y + o.Height
53+ }
54+ }
55+ for i := 0; i < n; i++ {
56+ o := l[i]
57+ next.regions[i] = Region{
58+ Layout: ports.Rect{X: o.X, Y: o.Y, W: o.Width, H: o.Height},
59+ X: ports.Rect{X: o.X - next.ox, Y: o.Y - next.oy, W: o.Width, H: o.Height},
60+ }
61+ }
62+ next.w, next.h = maxX-next.ox, maxY-next.oy
63+ if next.regions == s.regions && next.n == s.n && next.ox == s.ox && next.oy == s.oy &&
64+ next.w == s.w && next.h == s.h {
65+ s.dropped = next.dropped
66+ return false
67+ }
68+ *s = next
69+ return true
70+}
71+
72+// Size is the X screen size.
73+func (s *Space) Size() (w, h int) { return s.w, s.h }
74+
75+// nearest returns the index of the region containing the layout point, or
76+// the closest one by squared distance when it is in a hole. -1 when empty.
77+func (s *Space) nearest(lx, ly int) int {
78+ best, bestD := -1, 0
79+ for i := 0; i < s.n; i++ {
80+ r := s.regions[i].Layout
81+ dx := axisGap(lx, r.X, r.X+r.W)
82+ dy := axisGap(ly, r.Y, r.Y+r.H)
83+ d := dx*dx + dy*dy
84+ if best < 0 || d < bestD {
85+ best, bestD = i, d
86+ }
87+ }
88+ return best
89+}
90+
91+// nearestX is nearest in X space.
92+func (s *Space) nearestX(x, y int) int {
93+ best, bestD := -1, 0
94+ for i := 0; i < s.n; i++ {
95+ r := s.regions[i].X
96+ dx := axisGap(x, r.X, r.X+r.W)
97+ dy := axisGap(y, r.Y, r.Y+r.H)
98+ d := dx*dx + dy*dy
99+ if best < 0 || d < bestD {
100+ best, bestD = i, d
101+ }
102+ }
103+ return best
104+}
105+
106+// axisGap is the distance from v to [lo, hi): 0 inside.
107+func axisGap(v, lo, hi int) int {
108+ if v < lo {
109+ return lo - v
110+ }
111+ if v >= hi {
112+ return v - hi + 1
113+ }
114+ return 0
115+}
116+
117+// ToX converts a layout point to X, through the nearest region.
118+func (s *Space) ToX(lx, ly int) (x, y int) {
119+ i := s.nearest(lx, ly)
120+ if i < 0 {
121+ return lx, ly
122+ }
123+ r := &s.regions[i]
124+ return r.X.X + lx - r.Layout.X, r.X.Y + ly - r.Layout.Y
125+}
126+
127+// ToLayout converts an X point to layout coordinates.
128+func (s *Space) ToLayout(x, y int) (lx, ly int) {
129+ i := s.nearestX(x, y)
130+ if i < 0 {
131+ return x, y
132+ }
133+ r := &s.regions[i]
134+ return r.Layout.X + x - r.X.X, r.Layout.Y + y - r.X.Y
135+}
A internal/adapters/wayland/xwm/space_test.go +94 -0
@@ -0,0 +1,94 @@
1+package xwm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+func out(x, y, w, h int) ports.OutputPlacement {
10+ return ports.OutputPlacement{X: x, Y: y, Width: w, Height: h, Scale: 1}
11+}
12+
13+func TestSpaceLayouts(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ l ports.Layout
17+ w, h int
18+ lx, ly, x, y int
19+ }{
20+ {"single", ports.Layout{out(0, 0, 1920, 1080)}, 1920, 1080, 10, 20, 10, 20},
21+ {"side by side", ports.Layout{out(0, 0, 1920, 1080), out(1920, 0, 1280, 1024)}, 3200, 1080, 2000, 5, 2000, 5},
22+ {"left-of negative origin", ports.Layout{out(-640, 0, 640, 480), out(0, 0, 1920, 1080)}, 2560, 1080, -630, 20, 10, 20},
23+ {"above", ports.Layout{out(0, -500, 800, 500), out(0, 0, 800, 600)}, 800, 1100, 3, -400, 3, 100},
24+ {"below", ports.Layout{out(0, 0, 800, 600), out(0, 600, 800, 400)}, 800, 1000, 3, 700, 3, 700},
25+ {"hole nearest", ports.Layout{out(0, 0, 100, 100), out(300, 0, 100, 100)}, 400, 100, 120, 10, 120, 10},
26+ }
27+ for _, tc := range tests {
28+ t.Run(tc.name, func(t *testing.T) {
29+ var s Space
30+ if !s.Set(tc.l) {
31+ t.Fatal("first Set not changed")
32+ }
33+ if w, h := s.Size(); w != tc.w || h != tc.h {
34+ t.Fatalf("size %dx%d, want %dx%d", w, h, tc.w, tc.h)
35+ }
36+ x, y := s.ToX(tc.lx, tc.ly)
37+ if x != tc.x || y != tc.y {
38+ t.Fatalf("ToX = %d,%d want %d,%d", x, y, tc.x, tc.y)
39+ }
40+ if lx, ly := s.ToLayout(x, y); lx != tc.lx || ly != tc.ly {
41+ t.Fatalf("round trip = %d,%d want %d,%d", lx, ly, tc.lx, tc.ly)
42+ }
43+ if s.Set(tc.l) {
44+ t.Fatal("identical Set reported changed")
45+ }
46+ })
47+ }
48+}
49+
50+func TestSpaceEmptyAndOrigin(t *testing.T) {
51+ var s Space
52+ if x, y := s.ToX(5, 6); x != 5 || y != 6 {
53+ t.Fatalf("empty ToX = %d,%d", x, y)
54+ }
55+ s.Set(ports.Layout{out(-640, -20, 640, 480), out(0, 0, 1920, 1080)})
56+ if ox, oy := s.Origin(); ox != -640 || oy != -20 {
57+ t.Fatalf("origin = %d,%d", ox, oy)
58+ }
59+}
60+
61+func TestSpaceSeventeenOutputs(t *testing.T) {
62+ l := make(ports.Layout, 17)
63+ for i := range l {
64+ l[i] = out(i*100, 0, 100, 100)
65+ }
66+ var s Space
67+ s.Set(l)
68+ if s.Dropped() != 1 {
69+ t.Fatalf("dropped = %d", s.Dropped())
70+ }
71+ if w, _ := s.Size(); w != 1600 {
72+ t.Fatalf("width = %d", w)
73+ }
74+ if x, _ := s.ToX(1550, 5); x != 1550 {
75+ t.Fatalf("ToX = %d", x)
76+ }
77+}
78+
79+func TestSpaceAllocations(t *testing.T) {
80+ l := ports.Layout{out(-640, 0, 640, 480), out(0, 0, 1920, 1080)}
81+ var s Space
82+ s.Set(l)
83+ n := testing.AllocsPerRun(100, func() {
84+ s.Set(l)
85+ x, y := s.ToX(-600, 10)
86+ x, y = s.ToLayout(x, y)
87+ _, _ = x, y
88+ _, _ = s.Origin()
89+ _, _ = s.Size()
90+ })
91+ if n != 0 {
92+ t.Fatalf("allocs = %v", n)
93+ }
94+}
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 +241 -0
@@ -0,0 +1,241 @@
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+// ovRect is a layout rectangle.
32+type ovRect struct{ x, y, w, h int32 }
33+
34+// record is one X window known to the XWM. Strings are the only per-record
35+// heap data.
36+type record struct {
37+ xid uint32
38+ id ports.WindowID
39+ kind uint8
40+ gen uint16 // bumped on insert; stale Expect replies are told apart by it
41+
42+ // X state
43+ mapped, mapping, ready bool
44+ x, y int32
45+ w, h uint32
46+
47+ // properties
48+ pending uint16 // propMask bits still awaited
49+ input, deleteWindow, takeFocus bool
50+ wtype uint8
51+ modal, fullscreen bool
52+ transient uint32 // xid
53+ pid int32
54+ minW, minH, maxW, maxH int32
55+ title, class string // owned, replaced only on change
56+ hasNetName bool
57+ floating bool // dialog-like type; set with the properties
58+ sized bool // the client asked for its size before Ready
59+
60+ // pairing
61+ serial uint64
62+ surface uint64
63+
64+ // layout position of a managed window, from Place
65+ lx, ly int32
66+ placed bool
67+
68+ // unmanaged (override-redirect) windows
69+ noInput bool // tooltip, DND, notification: never takes the pointer
70+ ovPaired bool // OverrideMapped sent, OverrideGone owed
71+ ovQueued bool // slot is in XWM.ovDirty
72+ ov ovRect // layout rect last sent to the host
73+
74+ // configure
75+ pend, sent pending
76+ stateSent uint8 // _NET_WM_STATE bits last written
77+}
78+
79+// table is a slab of records with an open-addressing index by X id. No Go map.
80+type table struct {
81+ recs []record // slab, len == cap, power of two
82+ free []uint32 // free slot indices (LIFO)
83+ index []uint32 // slot+1, 0 empty, indexGone tombstone; len == 2*len(recs)
84+ shift uint8
85+ n int
86+ tombs int
87+}
88+
89+func newTable(size int) *table {
90+ t := &table{}
91+ t.resize(size)
92+ return t
93+}
94+
95+// resize reallocates the slab, free list and index for size records and
96+// reindexes the live ones.
97+func (t *table) resize(size int) {
98+ old := t.recs
99+ t.recs = make([]record, size)
100+ copy(t.recs, old)
101+ t.free = make([]uint32, 0, size)
102+ for i := min(size, maxSlots) - 1; i >= 0; i-- {
103+ if i >= len(old) || old[i].kind == kindFree {
104+ t.free = append(t.free, uint32(i))
105+ }
106+ }
107+ t.index = make([]uint32, 2*size)
108+ t.shift = uint8(32 - bits.TrailingZeros(uint(len(t.index))))
109+ t.reindex()
110+}
111+
112+// reindex rebuilds the index from the live records, dropping tombstones.
113+// It does not allocate.
114+func (t *table) reindex() {
115+ clear(t.index)
116+ t.tombs = 0
117+ for i := range t.recs {
118+ if t.recs[i].kind != kindFree {
119+ t.indexAdd(t.recs[i].xid, uint32(i)+1)
120+ }
121+ }
122+}
123+
124+func (t *table) hash(xid uint32) uint32 { return (xid * 0x9E3779B1) >> t.shift }
125+
126+// indexAdd places slot for xid, which must be absent, in the first empty or
127+// tombstone position of its probe sequence.
128+func (t *table) indexAdd(xid, slot uint32) {
129+ mask := uint32(len(t.index) - 1)
130+ for i := t.hash(xid); ; i = (i + 1) & mask {
131+ switch t.index[i] {
132+ case 0:
133+ t.index[i] = slot
134+ return
135+ case indexGone:
136+ t.index[i] = slot
137+ t.tombs--
138+ return
139+ }
140+ }
141+}
142+
143+// grow doubles the table. It reports false at maxSlots.
144+func (t *table) grow() bool {
145+ if len(t.recs) >= 1<<16 {
146+ return false
147+ }
148+ t.resize(2 * len(t.recs))
149+ return len(t.free) > 0
150+}
151+
152+// insert returns the slot of xid, creating a managed record (generation
153+// bumped) when absent. It returns 0 when the table is full. Growth is the
154+// only allocation.
155+func (t *table) insert(xid uint32) Slot {
156+ if s := t.lookup(xid); s != 0 {
157+ return s
158+ }
159+ if len(t.free) == 0 && !t.grow() {
160+ return 0
161+ }
162+ i := t.free[len(t.free)-1]
163+ t.free = t.free[:len(t.free)-1]
164+ if t.n+t.tombs+1 > len(t.index)/4*3 { // keep an empty index cell for lookup to stop at
165+ t.reindex()
166+ }
167+ r := &t.recs[i]
168+ gen := r.gen + 1
169+ if gen == 0 {
170+ gen = 1
171+ }
172+ *r = record{xid: xid, kind: kindManaged, gen: gen, input: true}
173+ t.indexAdd(xid, i+1)
174+ t.n++
175+ return Slot(i + 1)
176+}
177+
178+// remove frees s. The record is zeroed except its generation.
179+func (t *table) remove(s Slot) {
180+ r := t.rec(s)
181+ if r == nil || r.kind == kindFree {
182+ return
183+ }
184+ mask := uint32(len(t.index) - 1)
185+ for i := t.hash(r.xid); ; i = (i + 1) & mask {
186+ v := t.index[i]
187+ if v == 0 {
188+ break
189+ }
190+ if v == uint32(s) {
191+ t.index[i] = indexGone
192+ t.tombs++
193+ break
194+ }
195+ }
196+ *r = record{gen: r.gen}
197+ t.free = append(t.free, uint32(s)-1)
198+ t.n--
199+ if t.n+t.tombs > len(t.index)/4*3 {
200+ t.reindex()
201+ }
202+}
203+
204+// lookup returns the slot of xid, or 0.
205+func (t *table) lookup(xid uint32) Slot {
206+ mask := uint32(len(t.index) - 1)
207+ for i := t.hash(xid); ; i = (i + 1) & mask {
208+ v := t.index[i]
209+ if v == 0 {
210+ return 0
211+ }
212+ if v != indexGone && t.recs[v-1].xid == xid {
213+ return Slot(v)
214+ }
215+ }
216+}
217+
218+// rec returns the record of s, or nil when s is out of range. The pointer is
219+// valid only until the next insert, host call or anything else that can grow
220+// the table or free s: never hold it across those, fetch it again with rec(s).
221+func (t *table) rec(s Slot) *record {
222+ if s == 0 || int(s) > len(t.recs) {
223+ return nil
224+ }
225+ return &t.recs[s-1]
226+}
227+
228+// tag is the Expect argument naming s: slot in the low 16 bits, generation
229+// in the high 16.
230+func (t *table) tag(s Slot) uint32 { return uint32(s) | uint32(t.recs[s-1].gen)<<16 }
231+
232+// live resolves an Expect tag. It returns nil when the slot was freed or
233+// reused since the request was sent.
234+func (t *table) live(tag uint32) (Slot, *record) {
235+ s := Slot(tag & 0xFFFF)
236+ r := t.rec(s)
237+ if r == nil || r.kind == kindFree || r.gen != uint16(tag>>16) {
238+ return 0, nil
239+ }
240+ return s, r
241+}
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/unmanaged.go +109 -0
@@ -0,0 +1,109 @@
1+package xwm
2+
3+import (
4+ "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/xproto"
6+)
7+
8+// Override-redirect windows are tracked, never managed: the XWM reads their
9+// window type once at map, pairs them with their wl_surface and reports the
10+// mapped rect to the host in layout coordinates.
11+
12+// maxTypeWords bounds the _NET_WM_WINDOW_TYPE read.
13+const maxTypeWords = 32
14+
15+// requestOverrideType asks for the window type of a freshly mapped override
16+// window; the reply completes its readiness.
17+func (x *XWM) requestOverrideType(s Slot) {
18+ rec := x.tab.rec(s)
19+ seq := xproto.GetProperty(x.conn.Buf(), false, xproto.Window(rec.xid),
20+ xproto.Atom(x.atoms[atomNetWMWindowType]), xproto.AtomAny, 0, maxTypeWords)
21+ x.expect(seq, kindOverrideType, x.tab.tag(s), 0)
22+ x.wake()
23+}
24+
25+func (x *XWM) onOverrideType(s Slot, rec *record, r *xturbo.Reply) {
26+ rec.noInput = false
27+ if rep, ok := xproto.ParseGetPropertyReply(r.Raw); ok && rep.Format() == 32 {
28+ val := rep.Value()
29+ for i := 0; i+4 <= len(val); i += 4 {
30+ switch x.windowType(word(val, i/4)) {
31+ case atomNetWMWindowTypeTooltip, atomNetWMWindowTypeNotification, atomNetWMWindowTypeDND:
32+ rec.noInput = true
33+ }
34+ }
35+ }
36+ x.overrideReady(s)
37+}
38+
39+// overrideReady marks a mapped override window typed and pairs it.
40+func (x *XWM) overrideReady(s Slot) {
41+ rec := x.tab.rec(s)
42+ if rec == nil || rec.kind != kindUnmanaged || !rec.mapped {
43+ return
44+ }
45+ rec.ready = true
46+ x.tryPair(s)
47+ x.overrideMapped(s, rec)
48+}
49+
50+// overrideMapped reports the window once it is mapped, typed and paired.
51+func (x *XWM) overrideMapped(s Slot, rec *record) {
52+ if x.host == nil || rec.ovPaired || !rec.mapped || !rec.ready || rec.surface == 0 {
53+ return
54+ }
55+ rec.ovPaired = true
56+ rec.ov = x.layoutRect(rec)
57+ o := rec.ov
58+ x.host.OverrideMapped(s, rec.xid, rec.surface, int(o.x), int(o.y), int(o.w), int(o.h), rec.noInput)
59+}
60+
61+// overrideUnmapped ends the mapped life of an override window; a remap reads
62+// the type again and pairs with a new surface.
63+func (x *XWM) overrideUnmapped(s Slot, rec *record) {
64+ rec.ready, rec.serial, rec.surface, rec.noInput = false, 0, 0, false
65+ rec.gen++ // a type reply still in flight names a dead tag
66+ if rec.gen == 0 {
67+ rec.gen = 1
68+ }
69+ if rec.ovPaired {
70+ rec.ovPaired = false
71+ x.host.OverrideGone(s)
72+ }
73+}
74+
75+func (x *XWM) layoutRect(rec *record) ovRect {
76+ lx, ly := x.space.ToLayout(int(rec.x), int(rec.y))
77+ return ovRect{int32(lx), int32(ly), int32(rec.w), int32(rec.h)}
78+}
79+
80+// queueOverride schedules a geometry report for s; flushOverrides sends it
81+// once per iteration, only if the layout rect changed.
82+func (x *XWM) queueOverride(s Slot, rec *record) {
83+ if rec.ovQueued || s == 0 {
84+ return
85+ }
86+ if len(x.ovDirty) == cap(x.ovDirty) {
87+ x.flushOverrides()
88+ }
89+ rec.ovQueued = true
90+ x.ovDirty = append(x.ovDirty, s)
91+}
92+
93+func (x *XWM) flushOverrides() {
94+ for _, s := range x.ovDirty {
95+ rec := x.tab.rec(s)
96+ if rec == nil || rec.kind != kindUnmanaged || !rec.ovQueued {
97+ continue
98+ }
99+ rec.ovQueued = false
100+ if !rec.ovPaired {
101+ continue
102+ }
103+ if o := x.layoutRect(rec); o != rec.ov {
104+ rec.ov = o
105+ x.host.OverrideMoved(s, int(o.x), int(o.y), int(o.w), int(o.h))
106+ }
107+ }
108+ x.ovDirty = x.ovDirty[:0]
109+}
A internal/adapters/wayland/xwm/unmanaged_test.go +247 -0
@@ -0,0 +1,247 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func layoutOf(os ...ports.OutputPlacement) ports.Layout { return ports.Layout(os) }
14+
15+func rootConfigureNotify(win uint32, x, y int16, w, h uint16) xturbo.Event {
16+ ev := xturbo.Event{Kind: xproto.KindConfigureNotify}
17+ binary.LittleEndian.PutUint32(ev.Raw[4:], peerRoot)
18+ binary.LittleEndian.PutUint32(ev.Raw[8:], win)
19+ binary.LittleEndian.PutUint16(ev.Raw[16:], uint16(x))
20+ binary.LittleEndian.PutUint16(ev.Raw[18:], uint16(y))
21+ binary.LittleEndian.PutUint16(ev.Raw[20:], w)
22+ binary.LittleEndian.PutUint16(ev.Raw[22:], h)
23+ return ev
24+}
25+
26+func (p *xpeer) rootConfigureNotify(win uint32, x, y int16, w, h uint16) {
27+ p.event(22, func(b *[32]byte) {
28+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
29+ binary.LittleEndian.PutUint32(b[8:], win)
30+ binary.LittleEndian.PutUint16(b[16:], uint16(x))
31+ binary.LittleEndian.PutUint16(b[18:], uint16(y))
32+ binary.LittleEndian.PutUint16(b[20:], w)
33+ binary.LittleEndian.PutUint16(b[22:], h)
34+ })
35+}
36+
37+func TestPlaceConvertsLayoutToXCoordinates(t *testing.T) {
38+ x, p, h, _, _ := attached(t)
39+ s := newWin(t, x, p, h, 0x500001)
40+ x.SetLayout(layoutOf(out(-640, 0, 640, 480), out(0, 0, 1920, 1080)))
41+ n := settle(t, x, p)
42+
43+ x.Place(s, -640+10, 20)
44+ settle(t, x, p)
45+ cw := since(p, n, opConfigureWindow)
46+ require.Len(t, cw, 1)
47+ require.EqualValues(t, 10, cw[0].u32(12))
48+ require.EqualValues(t, 20, cw[0].u32(16))
49+}
50+
51+func TestSetLayoutRepositionsPlacedWindows(t *testing.T) {
52+ x, p, h, _, _ := attached(t)
53+ s := newWin(t, x, p, h, 0x500001)
54+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
55+ x.Place(s, 100, 50)
56+ n := settle(t, x, p)
57+
58+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080))) // unchanged: nothing resent
59+ settle(t, x, p)
60+ require.Empty(t, since(p, n, opConfigureWindow))
61+
62+ x.SetLayout(layoutOf(out(-100, 0, 100, 100), out(0, 0, 1920, 1080)))
63+ settle(t, x, p)
64+ cw := since(p, n, opConfigureWindow)
65+ require.Len(t, cw, 1)
66+ require.EqualValues(t, 200, cw[0].u32(12))
67+ require.EqualValues(t, 50, cw[0].u32(16))
68+}
69+
70+func TestSetLayoutWarnsOnceWhenOutputsDropped(t *testing.T) {
71+ x, _, _, _, _ := attached(t)
72+ var l ports.Layout
73+ for i := 0; i < maxRegions+2; i++ {
74+ l = append(l, out(i*100, 0, 100, 100))
75+ }
76+ x.SetLayout(l)
77+ require.True(t, x.warnedSpace)
78+ require.Equal(t, 2, x.space.Dropped())
79+}
80+
81+// overrideWindow creates and maps an override window, answers its type read
82+// and pairs it; the mocked host must expect OverrideMapped beforehand.
83+func overrideWindow(t *testing.T, x *XWM, p *xpeer, win uint32, typ string) Slot {
84+ t.Helper()
85+ if typ != "" {
86+ p.setProp(win, p.atom("_NET_WM_WINDOW_TYPE"), uint32(xproto.AtomAtom), 32, le32(p.atom(typ)))
87+ }
88+ p.createNotify(win, true)
89+ readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 })
90+ s := x.tab.lookup(win)
91+ x.feed(t, p, func() { p.rootConfigureNotify(win, 30, 40, 100, 50) })
92+ x.feed(t, p, func() { p.mapNotify(win) })
93+ return s
94+}
95+
96+func TestUnmanagedMapReadsTypeThenPairsThenReports(t *testing.T) {
97+ x, p, h, _, _ := attached(t)
98+ x.SetLayout(layoutOf(out(-640, 0, 640, 480), out(0, 0, 1920, 1080)))
99+ const win = 0x500009
100+ s := overrideWindow(t, x, p, win, "_NET_WM_WINDOW_TYPE_TOOLTIP")
101+ x.flush()
102+ readUntil(t, x, func() bool { return x.tab.rec(s).ready })
103+ typeReads := 0
104+ for _, q := range p.requests() {
105+ if q.major == opGetProperty && q.u32(4) == win {
106+ typeReads++
107+ }
108+ }
109+ require.Equal(t, 1, typeReads)
110+
111+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), -640+30, 40, 100, 50, true).Once()
112+ x.SetSerial(7, 9)
113+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
114+ require.True(t, x.tab.rec(s).ovPaired)
115+}
116+
117+func TestUnmanagedDefaultTypeTakesInput(t *testing.T) {
118+ x, p, h, _, _ := attached(t)
119+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
120+ const win = 0x500009
121+ s := overrideWindow(t, x, p, win, "_NET_WM_WINDOW_TYPE_POPUP_MENU")
122+ x.SetSerial(7, 9)
123+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
124+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
125+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
126+}
127+
128+func TestUnmanagedMoveAndLayoutChangeAndUnmap(t *testing.T) {
129+ x, p, h, _, _ := attached(t)
130+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
131+ const win = 0x500009
132+ s := overrideWindow(t, x, p, win, "")
133+ x.SetSerial(7, 9)
134+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
135+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
136+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
137+
138+ h.EXPECT().OverrideMoved(s, 35, 45, 100, 60).Once()
139+ x.feed(t, p, func() { p.rootConfigureNotify(win, 35, 45, 100, 60) })
140+ x.flush()
141+ x.feed(t, p, func() { p.rootConfigureNotify(win, 35, 45, 100, 60) }) // same rect: no call
142+ x.flush()
143+
144+ h.EXPECT().OverrideMoved(s, -65, 45, 100, 60).Once() // the layout origin moved: same X point, new layout point
145+ x.SetLayout(layoutOf(out(-100, 0, 100, 100), out(0, 0, 1920, 1080)))
146+ x.flush()
147+
148+ h.EXPECT().OverrideGone(s).Once()
149+ x.feed(t, p, func() { p.unmapNotify(win, false) })
150+ require.False(t, x.tab.rec(s).ovPaired)
151+}
152+
153+func TestUnmanagedDestroyReportsGone(t *testing.T) {
154+ x, p, h, _, _ := attached(t)
155+ const win = 0x500009
156+ s := overrideWindow(t, x, p, win, "")
157+ x.SetSerial(7, 9)
158+ h.EXPECT().OverrideMapped(s, uint32(win), uint64(7), 30, 40, 100, 50, false).Once()
159+ x.feed(t, p, func() { p.clientMessage(win, p.atom("WL_SURFACE_SERIAL"), 9, 0) })
160+ readUntil(t, x, func() bool { return x.tab.rec(s).ovPaired })
161+ h.EXPECT().OverrideGone(s).Once()
162+ x.feed(t, p, func() { p.destroyNotify(win) })
163+ require.Zero(t, x.tab.lookup(win))
164+}
165+
166+func TestFocusInIgnoresUnmanagedAndGrabModes(t *testing.T) {
167+ x, p, h, _, _ := attached(t)
168+ sa := newWin(t, x, p, h, 0x500001)
169+ newWin(t, x, p, h, 0x500002)
170+ x.Focus(sa)
171+ p.createNotify(0x500009, true)
172+ readUntil(t, x, func() bool { return x.tab.lookup(0x500009) != 0 })
173+ n := settle(t, x, p)
174+
175+ focusIn := func(win uint32, mode byte) {
176+ p.event(9, func(e *[32]byte) {
177+ binary.LittleEndian.PutUint32(e[4:], win)
178+ e[8] = mode
179+ })
180+ }
181+ x.feed(t, p, func() { focusIn(0x500009, 0) })
182+ x.feed(t, p, func() { focusIn(0x500002, byte(xproto.NotifyModeGrab)) })
183+ x.feed(t, p, func() { focusIn(0x500002, byte(xproto.NotifyModeUngrab)) })
184+ require.False(t, x.refocus)
185+ settle(t, x, p)
186+ require.Empty(t, since(p, n, opSetInputFocus))
187+
188+ x.feed(t, p, func() { focusIn(0x500002, 0) })
189+ settle(t, x, p)
190+ require.Len(t, since(p, n, opSetInputFocus), 1, "a normal FocusIn on another managed window is still denied")
191+}
192+
193+func TestWMChangeStateIgnored(t *testing.T) {
194+ x, p, h, _, _ := attached(t)
195+ newWin(t, x, p, h, 0x500001)
196+ n := settle(t, x, p)
197+ x.feed(t, p, func() { p.clientMessage(0x500001, p.atom("WM_CHANGE_STATE"), 3) })
198+ settle(t, x, p)
199+ require.Empty(t, since(p, n, opConfigureWindow)) // the mocked host sees no call either
200+}
201+
202+func TestUnmanagedGeometryAllocations(t *testing.T) {
203+ x, p, _, _, _ := attached(t)
204+ x.SetLayout(layoutOf(out(0, 0, 1920, 1080)))
205+ s := x.insert(0x500009)
206+ r := x.tab.rec(s)
207+ r.kind, r.mapped, r.ready, r.ovPaired = kindUnmanaged, true, true, true
208+ r.x, r.y, r.w, r.h = 30, 40, 100, 50
209+ r.ov = x.layoutRect(r)
210+ sinkPeer(t, x, p)
211+ a := rootConfigureNotify(0x500009, 31, 41, 100, 50)
212+ b := rootConfigureNotify(0x500009, 30, 40, 100, 50) // back to the reported rect
213+ allocs := testing.AllocsPerRun(1000, func() {
214+ x.onEvent(&a)
215+ x.onEvent(&b)
216+ x.flush()
217+ })
218+ require.Zero(t, allocs)
219+ require.True(t, r.ovPaired)
220+}
221+
222+func TestFocusInUnmanagedAllocations(t *testing.T) {
223+ x, p, _, _, _ := attached(t)
224+ s := x.insert(0x500009)
225+ x.tab.rec(s).kind = kindUnmanaged
226+ sinkPeer(t, x, p)
227+ ev := xturbo.Event{Kind: xproto.KindFocusIn}
228+ binary.LittleEndian.PutUint32(ev.Raw[4:], 0x500009)
229+ grab := ev
230+ grab.Raw[8] = byte(xproto.NotifyModeGrab)
231+ allocs := testing.AllocsPerRun(1000, func() {
232+ x.onEvent(&ev)
233+ x.onEvent(&grab)
234+ x.flush()
235+ })
236+ require.Zero(t, allocs)
237+ require.False(t, x.refocus)
238+}
239+
240+func TestDropWindowClearsQueuedOverride(t *testing.T) {
241+ x, _, _, _, _ := attached(t)
242+ s := x.insert(0x500009)
243+ r := x.tab.rec(s)
244+ r.kind, r.ovQueued = kindUnmanaged, true
245+ x.dropWindow(s)
246+ require.False(t, r.ovQueued)
247+}
A internal/adapters/wayland/xwm/xpeer_test.go +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 +451 -0
@@ -0,0 +1,451 @@
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+ space *Space // layout <-> X coordinates, shared with the host
85+ warnedSpace bool // more outputs than the Space holds, logged once
86+ ovDirty []Slot // mapped unmanaged slots whose geometry changed, capacity == table size
87+}
88+
89+func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
90+ x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}
91+ x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
92+ x.fit()
93+ return x
94+}
95+
96+// fit sizes the per-slot buffers to the table; called after every insert.
97+func (x *XWM) fit() {
98+ n := len(x.tab.recs)
99+ if cap(x.dirty) < n {
100+ x.dirty = append(make([]Slot, 0, n), x.dirty...)
101+ x.clients = append(make([]uint32, 0, n), x.clients...)
102+ x.ovDirty = append(make([]Slot, 0, n), x.ovDirty...)
103+ x.stacking = append(make([]uint32, 0, n), x.stacking...)
104+ x.scratch = make([]byte, 0, 4*n)
105+ }
106+}
107+
108+// insert adds a record for xid; 0 means the table is full.
109+func (x *XWM) insert(xid uint32) Slot {
110+ s := x.tab.insert(xid)
111+ if s == 0 {
112+ if !x.warnedFull {
113+ x.warnedFull = true
114+ x.log.Warn().Int("windows", x.tab.n).Msg("X11 window table full, window ignored")
115+ }
116+ return 0
117+ }
118+ x.fit()
119+ return s
120+}
121+
122+// expect registers seq, tolerating a full ring: the reply then arrives
123+// unattributed and is dropped.
124+func (x *XWM) expect(seq xturbo.Seq, kind uint16, a0, a1 uint32) {
125+ if seq == 0 {
126+ return
127+ }
128+ if err := x.conn.Expect(seq, kind, a0, a1, 0, 0); err != nil {
129+ x.stats.RingFull++
130+ if !x.warnedRing {
131+ x.warnedRing = true
132+ x.log.Warn().Err(err).Msg("X11 request not tracked")
133+ }
134+ }
135+}
136+
137+// setProp32 queues ChangeProperty (replace, format 32) for vals.
138+func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
139+ n := 4 * len(vals)
140+ if cap(x.scratch) < n {
141+ x.scratch = make([]byte, 0, n)
142+ }
143+ p := x.scratch[:n]
144+ for i, v := range vals {
145+ binary.LittleEndian.PutUint32(p[4*i:], v)
146+ }
147+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(win),
148+ xproto.Atom(x.atoms[prop]), typ, 32, uint32(len(vals)), p)
149+}
150+
151+// Boot connects to the X server on wmFD and becomes its window manager. It
152+// blocks (about 25 s at worst) and must not run on the display goroutine: the
153+// handshake needs Xwayland, which needs the display goroutine to serve its
154+// Wayland requests. It takes ownership of wmFD and closes it on failure.
155+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
156+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
157+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
158+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
159+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
160+ // can never reach a descriptor number reused after Boot closed wmFD.
161+ var watch *os.File
162+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
163+ watch = os.NewFile(uintptr(dup), "xwm abort")
164+ }
165+ stopWatch := func() {}
166+ if watch != nil {
167+ quit, joined := make(chan struct{}), make(chan struct{})
168+ go func() {
169+ defer close(joined)
170+ select {
171+ case <-ctx.Done():
172+ if rc, err := watch.SyscallConn(); err == nil {
173+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
174+ }
175+ case <-quit:
176+ }
177+ _ = watch.Close()
178+ }()
179+ stopWatch = func() { close(quit); <-joined }
180+ }
181+ x, err := boot(wmFD, log)
182+ stopWatch()
183+ if err == nil && ctx.Err() != nil {
184+ x.CloseConn()
185+ x, err = nil, ctx.Err()
186+ }
187+ if err != nil && ctx.Err() != nil {
188+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
189+ }
190+ return x, err
191+}
192+
193+func boot(wmFD int, log zerowrap.Logger) (*XWM, error) {
194+ conn, err := xturbo.NewConn(wmFD)
195+ if err != nil {
196+ return nil, fmt.Errorf("xwm: connect: %w", err)
197+ }
198+ x := newXWM(conn, log)
199+ if err := x.boot(); err != nil {
200+ _ = conn.Close()
201+ return nil, err
202+ }
203+ return x, nil
204+}
205+
206+func (x *XWM) boot() error {
207+ conn := x.conn
208+ ext := conn.Ext()
209+ if !ext.Info(xturbo.ExtComposite).Present {
210+ return ErrNoComposite
211+ }
212+ var visual, depth uint32
213+ if it := xproto.NewSetupIter(conn.SetupBytes(), 1); it.Next() {
214+ if roots := it.Value().Roots(); roots.Next() {
215+ visual, depth = uint32(roots.Value().RootVisual()), uint32(roots.Value().RootDepth())
216+ }
217+ }
218+ x.log.Info().Uint32("root", x.root).Uint32("visual", visual).Uint32("depth", depth).
219+ Bool("xfixes", ext.Info(xturbo.ExtXFixes).Present).
220+ Bool("res", ext.Info(xturbo.ExtRes).Present).Msg("X server")
221+
222+ if err := conn.InternAtoms(atomNames[:], false, x.atoms[:]); err != nil {
223+ return fmt.Errorf("xwm: intern atoms: %w", err)
224+ }
225+ xid, err := conn.NewXID()
226+ if err != nil {
227+ return fmt.Errorf("xwm: allocate window id: %w", err)
228+ }
229+ x.wmWin = xid
230+
231+ b := conn.Buf()
232+ x.expect(composite.QueryVersion(b, 0, 4), kindVersion, versionComposite, 0)
233+ if ext.Info(xturbo.ExtXFixes).Present {
234+ x.expect(xfixes.QueryVersion(b, 6, 0), kindVersion, versionXFixes, 0)
235+ }
236+ if ext.Info(xturbo.ExtRes).Present {
237+ x.expect(res.QueryVersion(b, 1, 2), kindVersion, versionRes, 0)
238+ }
239+
240+ root := xproto.Window(x.root)
241+ var rootMask xproto.ChangeWindowAttributesValues
242+ rootMask.SetEventMask(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
243+ x.expect(xproto.ChangeWindowAttributes(b, root, &rootMask), kindRootMask, 0, 0)
244+ // Only one client may select SubstructureRedirect on the root: wait for
245+ // the answer before touching anything another window manager owns.
246+ if err := b.Err(); err != nil {
247+ return fmt.Errorf("xwm: encode root mask: %w", err)
248+ }
249+ if err := conn.Sync(&x.h); err != nil {
250+ return fmt.Errorf("xwm: root mask: %w", err)
251+ }
252+ if x.bootErr != nil {
253+ return x.bootErr
254+ }
255+ composite.RedirectSubwindows(b, root, composite.RedirectManual)
256+
257+ var wv xproto.CreateWindowValues
258+ wv.SetEventMask(0)
259+ xproto.CreateWindow(b, 0, xproto.Window(x.wmWin), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOutput, 0, &wv)
260+ name := []byte("neferwl")
261+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMName]),
262+ xproto.Atom(x.atoms[atomUTF8String]), 8, uint32(len(name)), name)
263+ x.setProp32(x.root, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
264+ x.setProp32(x.wmWin, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
265+ // CurrentTime and no MANAGER announcement: this Xwayland is private to
266+ // neferwl and nobody waits on these selections.
267+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomWMS0]), 0)
268+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMCMS0]), 0)
269+
270+ supported := [...]int{
271+ atomNetWMState, atomNetActiveWindow, atomNetWMStateFocused,
272+ atomNetWMStateModal, atomNetWMStateFullscreen, atomNetClientList,
273+ atomNetClientListStacking, atomNetSupportingWMCheck, atomNetWMName, atomNetWMPID,
274+ atomNetWMWindowType,
275+ }
276+ var list [len(supported)]uint32
277+ for i, a := range supported {
278+ list[i] = x.atoms[a]
279+ }
280+ x.setProp32(x.root, atomNetSupported, xproto.AtomAtom, list[:])
281+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, []uint32{0})
282+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, nil)
283+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, nil)
284+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
285+ if err := b.Err(); err != nil {
286+ return fmt.Errorf("xwm: encode setup requests: %w", err)
287+ }
288+ if err := conn.Sync(&x.h); err != nil {
289+ return fmt.Errorf("xwm: root setup: %w", err)
290+ }
291+ if x.bootErr != nil {
292+ return x.bootErr
293+ }
294+
295+ // Windows that exist already (Xwayland restarted under a live client set).
296+ x.expect(xproto.QueryTree(b, root), kindTree, 0, 0)
297+ if err := conn.Sync(&x.h); err != nil {
298+ return fmt.Errorf("xwm: query tree: %w", err)
299+ }
300+ return x.bootErr
301+}
302+
303+// Attach registers the X socket with the display and hands the windows found
304+// at boot to h. Display goroutine only.
305+func (x *XWM) Attach(d *server.Display, h host) error {
306+ src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
307+ if err != nil {
308+ return fmt.Errorf("xwm: watch X socket: %w", err)
309+ }
310+ x.attach(src, h, d.Post)
311+ return nil
312+}
313+
314+// attach is Attach over its seams: the event source and the display's Post.
315+func (x *XWM) attach(src fdSource, h host, post func(func())) {
316+ x.src, x.host, x.post = src, h, post
317+ x.flushFn = func() {
318+ x.posted = false
319+ x.flush()
320+ }
321+ for i := range x.tab.recs {
322+ r := &x.tab.recs[i]
323+ if r.kind == kindUnmanaged && r.mapped && !r.ready { // mapped before Attach
324+ x.requestOverrideType(Slot(i + 1))
325+ }
326+ if r.kind != kindManaged || r.id != 0 {
327+ continue
328+ }
329+ s := Slot(i + 1)
330+ x.adopt(s)
331+ if r.mapping { // MapRequest or viewable before Attach
332+ r.mapping = false
333+ x.beginMap(s)
334+ }
335+ }
336+ x.flush()
337+}
338+
339+// Close detaches from the display, drops every window from the host and closes
340+// the X connection. Idempotent.
341+func (x *XWM) Close() {
342+ if x.closed {
343+ return
344+ }
345+ x.closed = true
346+ s := x.stats
347+ x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
348+ Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).
349+ Uint64("legacy_id", s.LegacyID).Uint64("unmanaged_seen", s.UnmanagedSeen).Msg("X11 window management closed")
350+ if x.src != nil {
351+ x.src.Remove()
352+ x.src = nil
353+ }
354+ if x.host != nil {
355+ for i := range x.tab.recs {
356+ r := &x.tab.recs[i]
357+ switch {
358+ case r.kind == kindManaged && r.id != 0:
359+ r.id = 0
360+ x.host.Dissociate(Slot(i+1), true)
361+ case r.kind == kindUnmanaged && r.ovPaired:
362+ r.ovPaired = false
363+ x.host.OverrideGone(Slot(i + 1))
364+ }
365+ }
366+ }
367+ x.unpairedN = 0
368+ _ = x.conn.Close()
369+}
370+
371+// CloseConn closes the connection of an XWM that was never attached.
372+func (x *XWM) CloseConn() {
373+ if !x.closed {
374+ x.closed = true
375+ _ = x.conn.Close()
376+ }
377+}
378+
379+// fail ends window management after an unrecoverable connection error. Xwayland
380+// keeps running; its windows no longer map.
381+func (x *XWM) fail(err error) {
382+ if x.closed {
383+ return
384+ }
385+ if errors.Is(err, io.EOF) || errors.Is(err, xturbo.ErrClosed) {
386+ x.log.Info().Err(err).Msg("X11 window management stopped: Xwayland closed the connection")
387+ } else {
388+ x.log.Warn().Err(err).Msg("X11 window management stopped")
389+ }
390+ x.Close()
391+}
392+
393+// Stats returns the counters; Close logs them.
394+func (x *XWM) Stats() Stats { return x.stats }
395+
396+func (x *XWM) onReadable() {
397+ if x.closed {
398+ return
399+ }
400+ if err := x.conn.Dispatch(&x.h); err != nil {
401+ x.fail(err)
402+ return
403+ }
404+ x.flush()
405+}
406+
407+func (x *XWM) onWritable() { x.flush() }
408+
409+// wake schedules one flush at the end of the current display iteration.
410+func (x *XWM) wake() {
411+ if x.posted || x.closed || x.post == nil {
412+ return
413+ }
414+ x.posted = true
415+ x.post(x.flushFn)
416+}
417+
418+// flush sends what the handlers queued: pending configures, an XID prefetch,
419+// then the socket, arming POLLOUT only while it is full.
420+func (x *XWM) flush() {
421+ if x.closed {
422+ return
423+ }
424+ x.flushConfigures()
425+ if x.conn.XIDsLow() {
426+ _ = x.conn.PrefetchXIDs()
427+ }
428+ err := x.conn.Flush()
429+ switch {
430+ case err == nil:
431+ if x.writable {
432+ x.setWritable(false)
433+ }
434+ case errors.Is(err, xturbo.ErrWouldBlock):
435+ if !x.writable {
436+ x.setWritable(true)
437+ }
438+ default:
439+ x.fail(err)
440+ }
441+}
442+
443+func (x *XWM) setWritable(on bool) {
444+ x.writable = on
445+ if x.src == nil {
446+ return
447+ }
448+ if err := x.src.SetWritable(on); err != nil {
449+ x.fail(err)
450+ }
451+}
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 on1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2-// demand: neferwl owns the X11 display sockets and starts the satellite2+// sockets and starts the selected backend when the first client connects.
3-// when the first X11 client connects, and again after it exits.
4 package xwayland3 package xwayland
5 4
6 import (5 import (
@@ -17,15 +16,26 @@ import (
17 "syscall"16 "syscall"
18 "time"17 "time"
19 18
19+ "github.com/bnema/neferwl/internal/ports"
20 "github.com/bnema/zerowrap"20 "github.com/bnema/zerowrap"
21 "golang.org/x/sys/unix"21 "golang.org/x/sys/unix"
22 )22 )
23 23
24 // Options configures the X11 display.24 // Options configures the X11 display.
25 type Options struct {25 type Options struct {
26- // Binary is the xwayland-satellite executable.26+ Backend ports.XwaylandBackend
27+ // Attach takes ownership of the Wayland descriptor right after the backend
28+ // starts.
29+ Attach func(wlFD int)
30+ // AttachWM takes ownership of the window manager descriptor once the
31+ // backend is ready. It may block to bootstrap the X11 connection; ctx ends
32+ // when the backend exits or the run is cancelled, and the bootstrap must
33+ // then give up. The returned stop (nil for none) is called before the run
34+ // ends, on backend exit or context cancellation.
35+ AttachWM func(ctx context.Context, wmFD int) (stop func())
36+ // Binary is the selected backend executable.
27 Binary string37 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.38+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
29 Env []string39 Env []string
30 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix40 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
31 // and /tmp outside tests.41 // and /tmp outside tests.
@@ -35,8 +45,9 @@ type Options struct {
35 Log zerowrap.Logger45 Log zerowrap.Logger
36 }46 }
37 47
38-// Display is an X11 display served by xwayland-satellite.48+// Display is an on-demand X11 display.
39 type Display struct {49 type Display struct {
50+ starter starter
40 opts Options51 opts Options
41 n int52 n int
42 sockets []*os.File // unix socket, then the abstract one53 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) {
81 _ = os.Remove(lock)92 _ = os.Remove(lock)
82 return nil, fmt.Errorf("write X11 lock: %w", err)93 return nil, fmt.Errorf("write X11 lock: %w", err)
83 }94 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}95+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
96+ if opts.Backend == ports.XwaylandBuiltin {
97+ d.starter = builtinStarter{}
98+ }
85 if err := d.listen(); err != nil {99 if err := d.listen(); err != nil {
86 last = err100 last = err
87 d.Close()101 d.Close()
@@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error {
212 return err226 return err
213 }227 }
214 start := time.Now()228 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}229+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
216- err := d.serve(ctx, stderr)230+ err := d.starter.start(ctx, d, stderr)
217 if ctx.Err() != nil {231 if ctx.Err() != nil {
218 return nil232 return nil
219 }233 }
220 if err != nil {234 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")235+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222 } else {236 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")237+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224 }238 }
225 // Clients the satellite never accepted would wake us at once: a239 // Clients the satellite never accepted would wake us at once: a
226 // satellite that cannot start must not respawn in a loop.240 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261 return ctx.Err()275 return ctx.Err()
262 }276 }
263 277
264-// serve runs the satellite on the sockets until it exits or ctx ends.278+// starter runs a backend on the reserved sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {279+type starter interface {
280+ start(ctx context.Context, d *Display, stderr *logWriter) error
281+}
282+
283+type satelliteStarter struct{}
284+
285+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266 args := []string{d.Name()}286 args := []string{d.Name()}
267 for i := range d.sockets {287 for i := range d.sockets {
268 args = append(args, "-listenfd", strconv.Itoa(3+i))288 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +338,11 @@ func (d *Display) dropPending() {
318 // logWriter logs the satellite's stderr line by line and keeps the last338 // logWriter logs the satellite's stderr line by line and keeps the last
319 // lines for the exit log; exec copies it from a pipe on its own goroutine.339 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320 type logWriter struct {340 type logWriter struct {
321- mu sync.Mutex341+ backend string
322- log zerowrap.Logger342+ mu sync.Mutex
323- line []byte343+ log zerowrap.Logger
324- last []string344+ line []byte
345+ last []string
325 }346 }
326 347
327 // tail is the last lines written.348 // tail is the last lines written.
@@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342 }363 }
343 if i > 0 {364 if i > 0 {
344 line := string(w.line[:i])365 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")366+ w.log.Debug().Str("line", line).Msg(w.backend)
346 if w.last = append(w.last, line); len(w.last) > 5 {367 if w.last = append(w.last, line); len(w.last) > 5 {
347 w.last = w.last[1:]368 w.last = w.last[1:]
348 }369 }
@@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354 }375 }
355 return len(p), nil376 return len(p), nil
356 }377 }
378+
379+func (d *Display) backendName() string {
380+ if d.opts.Backend == ports.XwaylandBuiltin {
381+ return "Xwayland"
382+ }
383+ return "xwayland-satellite"
384+}
M internal/adapters/xwayland/xwayland_test.go +207 -0
@@ -11,6 +11,8 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/neferwl/internal/adapters/logging"13 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"16 "golang.org/x/sys/unix"
15 )17 )
16 18
@@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270 }272 }
271 }273 }
272 }274 }
275+
276+func TestStarterSelection(t *testing.T) {
277+ for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} {
278+ opts := testOptions(t, "true")
279+ opts.Backend = backend
280+ d, err := Open(opts)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ defer d.Close()
285+ switch d.starter.(type) {
286+ case satelliteStarter:
287+ if backend != ports.XwaylandSatellite {
288+ t.Fatal("satellite selected")
289+ }
290+ case builtinStarter:
291+ if backend != ports.XwaylandBuiltin {
292+ t.Fatal("builtin selected")
293+ }
294+ default:
295+ t.Fatalf("unexpected starter %T", d.starter)
296+ }
297+ }
298+}
299+
300+func TestRunCallsStarter(t *testing.T) {
301+ d, err := Open(testOptions(t, "unused"))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ defer d.Close()
306+ ctx, cancel := context.WithCancel(t.Context())
307+ defer cancel()
308+ m := newMockstarter(t)
309+ d.starter = m
310+ m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error {
311+ if got != d || stderr == nil {
312+ t.Error("starter arguments")
313+ }
314+ cancel()
315+ return ctx.Err()
316+ }).Once()
317+ done := make(chan error, 1)
318+ go func() { done <- d.Run(ctx) }()
319+ unix.Close(dial(t, d.socket))
320+ select {
321+ case err := <-done:
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ case <-time.After(3 * time.Second):
326+ t.Fatal("Run did not stop")
327+ }
328+}
329+
330+func TestWaitReady(t *testing.T) {
331+ for _, tt := range []struct {
332+ name, data string
333+ wantErr bool
334+ }{
335+ {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true},
336+ } {
337+ t.Run(tt.name, func(t *testing.T) {
338+ var p [2]int
339+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
340+ t.Fatal(err)
341+ }
342+ defer unix.Close(p[0])
343+ if tt.data != "" {
344+ if _, err := unix.Write(p[1], []byte(tt.data)); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ unix.Close(p[1])
349+ joined, err := waitReady(t.Context(), p[0], make(chan error))
350+ if joined || (err != nil) != tt.wantErr {
351+ t.Fatalf("joined=%v err=%v", joined, err)
352+ }
353+ })
354+ }
355+ t.Run("exit", func(t *testing.T) {
356+ done := make(chan error, 1)
357+ done <- errors.New("exit")
358+ joined, err := waitReady(t.Context(), -1, done)
359+ if !joined || err == nil {
360+ t.Fatal(joined, err)
361+ }
362+ })
363+ t.Run("cancel", func(t *testing.T) {
364+ ctx, cancel := context.WithCancel(t.Context())
365+ cancel()
366+ joined, err := waitReady(ctx, -1, make(chan error))
367+ if joined || !errors.Is(err, context.Canceled) {
368+ t.Fatal(joined, err)
369+ }
370+ })
371+}
372+
373+// readyScript stands in for Xwayland: after a short delay it creates a marker
374+// file and reports readiness on the -displayfd descriptor when ready is true,
375+// then runs tail.
376+func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) {
377+ t.Helper()
378+ bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready")
379+ script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" +
380+ "sleep 0.3\n"
381+ if ready {
382+ script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n"
383+ }
384+ script += tail + "\n"
385+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
386+ t.Fatal(err)
387+ }
388+ return bin, marker
389+}
390+
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) {
392+ t.Helper()
393+ opts := testOptions(t, "")
394+ opts.Backend = ports.XwaylandBuiltin
395+ var marker string
396+ opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail)
397+ exists := func() bool { _, err := os.Stat(marker); return err == nil }
398+ opts.Attach = func(wlFD int) {
399+ if exists() {
400+ t.Error("Attach ran after readiness")
401+ }
402+ events = append(events, "attach")
403+ unix.Close(wlFD)
404+ }
405+ opts.AttachWM = func(ctx context.Context, wmFD int) func() {
406+ if !exists() {
407+ t.Error("AttachWM ran before readiness")
408+ }
409+ events = append(events, "attachWM")
410+ attachWM(ctx, wmFD)
411+ return func() { events = append(events, "stop") }
412+ }
413+ d, err := Open(opts)
414+ if err != nil {
415+ t.Fatal(err)
416+ }
417+ defer d.Close()
418+ _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()})
419+ return append(events, "returned")
420+}
421+
422+// AttachWM runs only after -displayfd readiness; stop runs before start
423+// returns, whether the process exits or the context ends.
424+func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
425+ for name, tc := range map[string]struct {
426+ tail string
427+ cancel bool
428+ }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} {
429+ t.Run(name, func(t *testing.T) {
430+ ctx, cancel := context.WithCancel(t.Context())
431+ defer cancel()
432+ events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) {
433+ if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434+ t.Errorf("wmFD not open: %v", err)
435+ }
436+ unix.Close(wmFD)
437+ if tc.cancel {
438+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
439+ }
440+ })
441+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
442+ t.Fatalf("events %s", got)
443+ }
444+ })
445+ }
446+}
447+
448+// A process that exits before reporting readiness never reaches AttachWM.
449+func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {})
451+ if got := strings.Join(events, ","); got != "attach,returned" {
452+ t.Fatalf("events %s", got)
453+ }
454+}
455+
456+// The AttachWM context ends when the process dies, so a bootstrap against a
457+// dead Xwayland gives up at once instead of waiting out its handshake.
458+func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) {
459+ ended := make(chan error, 1)
460+ start := time.Now()
461+ events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) {
462+ defer unix.Close(wmFD)
463+ select {
464+ case <-ctx.Done():
465+ ended <- ctx.Err()
466+ case <-time.After(5 * time.Second):
467+ ended <- nil
468+ }
469+ })
470+ if err := <-ended; err == nil {
471+ t.Fatal("AttachWM context did not end with the process")
472+ }
473+ if time.Since(start) > 3*time.Second {
474+ t.Fatalf("took %v", time.Since(start))
475+ }
476+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
477+ t.Fatalf("events %s", got)
478+ }
479+}
M internal/app/app.go +13 -6
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "os/exec"
8 "time"9 "time"
9 10
10 "github.com/bnema/neferwl/internal/adapters/logging"11 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188 }189 }
189 }190 }
190 191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns192+// openXwayland reserves an X11 display for the selected backend, or returns
192 // nil when X11 is off or unavailable: the session runs without it.193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
194- bin := opts.Config.Xwayland195+ cfg := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196 return nil198 return nil
197 }199 }
198- if !xwayland.Supported(ctx, bin) {200+ if cfg.Backend == ports.XwaylandBuiltin {
201+ if _, err := exec.LookPath(bin); err != nil {
202+ log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled")
203+ return nil
204+ }
205+ } else if !xwayland.Supported(ctx, bin) {
199 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200 return nil207 return nil
201 }208 }
202- d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})209+ d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203 if err != nil {210 if err != nil {
204 log.Warn().Err(err).Msg("X11 display disabled")211 log.Warn().Err(err).Msg("X11 display disabled")
205 return nil212 return nil
M internal/app/session.go +1 -1
@@ -152,7 +152,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152 s.statePath = statePath152 s.statePath = statePath
153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)153 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154 }154 }
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 {
156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())156 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157 }157 }
158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))158 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
A internal/app/testdata/x11probe/main.go +232 -0
@@ -0,0 +1,232 @@
1+// Command x11probe is a minimal X11 client for the Xwayland end-to-end test.
2+//
3+// It opens one window filled with solid green, prints "mapped WxH" after the
4+// first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5+// window manager sends WM_DELETE_WINDOW.
6+//
7+// With -override it also maps a magenta override-redirect window after the
8+// main one is mapped (prints "override mapped") and unmaps it after
9+// -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10+// it unmaps it instead when the main window loses focus (prints "dismissed").
11+package main
12+
13+import (
14+ "encoding/binary"
15+ "flag"
16+ "fmt"
17+ "os"
18+ "strconv"
19+ "strings"
20+ "time"
21+
22+ "golang.org/x/sys/unix"
23+
24+ "bnema.dev/xturbo"
25+ "bnema.dev/xturbo/shape"
26+ "bnema.dev/xturbo/xproto"
27+)
28+
29+const (
30+ green = 0x00ff00
31+ magenta = 0xff00ff
32+)
33+
34+func main() {
35+ if err := run(); err != nil {
36+ fmt.Fprintln(os.Stderr, "x11probe:", err)
37+ os.Exit(1)
38+ }
39+}
40+
41+func run() error {
42+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
43+ title := flag.String("title", "probe", "_NET_WM_NAME")
44+ size := flag.String("size", "300x200", "window size WxH")
45+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
46+ override := flag.Bool("override", false, "map a magenta override-redirect window after the main one")
47+ overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates")
48+ overrideSize := flag.String("override-size", "120x80", "override window size WxH")
49+ overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)")
50+ overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")
51+ overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")
52+ dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
53+ flag.Parse()
54+ w, h, ok := parseSize(*size)
55+ if !ok {
56+ return fmt.Errorf("bad -size %q", *size)
57+ }
58+
59+ c, err := xturbo.Dial("") // $DISPLAY
60+ if err != nil {
61+ return err
62+ }
63+ defer c.Close()
64+
65+ var atoms [7]uint32
66+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",
67+ "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP"}, false, atoms[:]); err != nil {
68+ return err
69+ }
70+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
71+ typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]
72+
73+ b := c.Buf()
74+ xwin, err := c.NewXID()
75+ if err != nil {
76+ return err
77+ }
78+ xgc, err := c.NewXID()
79+ if err != nil {
80+ return err
81+ }
82+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
83+
84+ var v xproto.CreateWindowValues
85+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)
86+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
87+
88+ wmClass := []byte("x11probe\x00" + *class + "\x00")
89+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
90+ name := []byte(*title)
91+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
92+ var del [4]byte
93+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
94+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
95+
96+ var gv xproto.CreateGCValues
97+ gv.SetForeground(green)
98+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
99+ if err := c.Flush(); err != nil {
100+ return err
101+ }
102+ time.Sleep(*sleep)
103+ xproto.MapWindow(b, win)
104+
105+ // The override-redirect window is created once the main window is mapped.
106+ var ovWin xproto.Window
107+ var ovAt, ovSz [2]int
108+ if *override {
109+ var ok1, ok2 bool
110+ x, y, found := strings.Cut(*overrideAt, ",")
111+ ovAt[0], ovAt[1] = atoi(x), atoi(y)
112+ ok1 = found
113+ ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize)
114+ if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") {
115+ return fmt.Errorf("bad -override-at/-override-size/-override-type")
116+ }
117+ }
118+ var ovDeadline time.Time // zero: no timed unmap pending
119+ unmapOverride := func(msg string) {
120+ if ovWin == 0 {
121+ return
122+ }
123+ xproto.UnmapWindow(b, ovWin)
124+ ovWin = 0
125+ ovDeadline = time.Time{}
126+ fmt.Println(msg)
127+ }
128+ mapOverride := func() error {
129+ id, err := c.NewXID()
130+ if err != nil {
131+ return err
132+ }
133+ ovWin = xproto.Window(id)
134+ var ov xproto.CreateWindowValues
135+ ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true)
136+ xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov)
137+ typ := typeMenu
138+ if *overrideType == "tooltip" {
139+ typ = typeTooltip
140+ }
141+ var tb [4]byte
142+ binary.LittleEndian.PutUint32(tb[:], typ)
143+ xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:])
144+ if *overrideShape {
145+ shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil)
146+ }
147+ xproto.MapWindow(b, ovWin)
148+ fmt.Println("override mapped")
149+ if *overrideFor > 0 {
150+ ovDeadline = time.Now().Add(*overrideFor)
151+ }
152+ return nil
153+ }
154+ var ovErr error
155+
156+ mapped, done := false, false
157+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
158+ hd := &xturbo.Handler{
159+ OnEvent: func(ev *xturbo.Event) {
160+ switch ev.Kind {
161+ case xproto.KindExpose:
162+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
163+ case xproto.KindConfigureNotify:
164+ e := xproto.ConfigureNotifyOf(ev)
165+ if e.Window() != win {
166+ break
167+ }
168+ cw, ch = e.Width(), e.Height()
169+ if !mapped {
170+ mapped = true
171+ fmt.Printf("mapped %dx%d\n", cw, ch)
172+ if *override {
173+ ovErr = mapOverride()
174+ }
175+ }
176+ case xproto.KindFocusIn:
177+ fmt.Println("focus")
178+ case xproto.KindFocusOut:
179+ if *dismiss && xproto.FocusOutOf(ev).Event() == win {
180+ unmapOverride("dismissed")
181+ }
182+ case xproto.KindClientMessage:
183+ e := xproto.ClientMessageOf(ev)
184+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
185+ done = true
186+ }
187+ }
188+ },
189+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
190+ }
191+ for !done {
192+ events := int16(unix.POLLIN)
193+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
194+ events |= unix.POLLOUT
195+ } else if err != nil {
196+ return err
197+ }
198+ timeout := -1
199+ if !ovDeadline.IsZero() {
200+ timeout = max(int(time.Until(ovDeadline).Milliseconds()), 0)
201+ }
202+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
203+ if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
204+ return err
205+ }
206+ if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
207+ unmapOverride("override unmapped")
208+ }
209+ if err := c.Dispatch(hd); err != nil {
210+ return err
211+ }
212+ if ovErr != nil {
213+ return ovErr
214+ }
215+ }
216+ return nil
217+}
218+
219+func parseSize(s string) (w, h int, ok bool) {
220+ ws, hs, found := strings.Cut(s, "x")
221+ if !found {
222+ return 0, 0, false
223+ }
224+ w, err1 := strconv.Atoi(ws)
225+ h, err2 := strconv.Atoi(hs)
226+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
227+}
228+
229+func atoi(s string) int {
230+ n, _ := strconv.Atoi(s)
231+ return n
232+}
A internal/app/xwayland_test.go +394 -0
@@ -0,0 +1,394 @@
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+// xwaylandEnv is a running headless compositor with the built-in XWM.
45+type xwaylandEnv struct {
46+ bin, display, statePath, shots string
47+ script *io.PipeWriter
48+}
49+
50+// startXwayland runs the compositor on one headless output and waits for its
51+// X11 display. It skips the test without Xwayland and stops everything when
52+// the test ends.
53+func startXwayland(t *testing.T) *xwaylandEnv {
54+ t.Helper()
55+ if _, err := exec.LookPath("Xwayland"); err != nil {
56+ t.Skip("Xwayland not on PATH")
57+ }
58+ // The compositor must not read or write the live session's state.
59+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
60+ if v, ok := os.LookupEnv(k); ok {
61+ os.Unsetenv(k)
62+ t.Cleanup(func() { os.Setenv(k, v) })
63+ }
64+ }
65+ runtime := t.TempDir()
66+ t.Setenv("XDG_RUNTIME_DIR", runtime)
67+ t.Setenv("XDG_STATE_HOME", t.TempDir())
68+ shots := t.TempDir()
69+ bin := buildProbe(t)
70+
71+ script, scriptW := io.Pipe()
72+ logs := &lockedBuffer{}
73+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
74+ cfg := config.Defaults()
75+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
76+ done := make(chan error, 1)
77+ go func() {
78+ done <- Run(ctx, Options{
79+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
80+ ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},
81+ })
82+ }()
83+ t.Cleanup(func() {
84+ if t.Failed() {
85+ t.Logf("compositor log:\n%s", logs.String())
86+ }
87+ cancel()
88+ _ = scriptW.Close()
89+ select {
90+ case err := <-done:
91+ if err != nil {
92+ t.Errorf("Run: %v", err)
93+ }
94+ case <-time.After(5 * time.Second):
95+ t.Error("Run did not stop")
96+ }
97+ })
98+
99+ var display string
100+ until(t, "X11 display in the log", func() bool {
101+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
102+ display = m[1]
103+ return true
104+ }
105+ return false
106+ })
107+ statePath, err := statefile.Path(runtime, "wayland-1")
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
112+}
113+
114+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
115+// up in the state with its class and pid, is drawn, tiles next to a second
116+// one, receives focus and is closed by the close-window bind.
117+func TestXwaylandProbe(t *testing.T) {
118+ e := startXwayland(t)
119+ bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
120+ first := startProbe(t, bin, display, "-title", "one")
121+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
122+
123+ var st statefile.State
124+ until(t, "one visible X11Probe window", func() bool {
125+ st = readState(statePath)
126+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
127+ })
128+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
129+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
130+ }
131+
132+ // The client draws green and the compositor shows it.
133+ id := st.Windows[0].ID
134+ until(t, "green window in latest.png", func() bool {
135+ r, ok := tileRect(readState(statePath), id)
136+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
137+ })
138+
139+ // Focus round trip: the compositor focused the window, the client saw it.
140+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
141+
142+ // A second probe tiles beside the first.
143+ second := startProbe(t, bin, display, "-title", "two")
144+ until(t, "two tiled windows", func() bool {
145+ st = readState(statePath)
146+ if len(st.Windows) != 2 {
147+ return false
148+ }
149+ for _, w := range st.Windows {
150+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
151+ return false
152+ }
153+ }
154+ return true
155+ })
156+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
157+
158+ // The close-window bind asks the focused (second) probe to close.
159+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
160+ t.Fatal(err)
161+ }
162+ select {
163+ case err := <-second.waited:
164+ if err != nil {
165+ t.Errorf("probe two exited with %v, want 0", err)
166+ }
167+ case <-time.After(2 * time.Second):
168+ t.Fatal("probe two did not exit within 2s of Super+q")
169+ }
170+ until(t, "one window left", func() bool {
171+ st = readState(statePath)
172+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
173+ })
174+ select {
175+ case err := <-first.waited:
176+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
177+ default:
178+ }
179+}
180+
181+// buildProbe builds testdata/x11probe.
182+func buildProbe(t *testing.T) string {
183+ t.Helper()
184+ bin := filepath.Join(t.TempDir(), "x11probe")
185+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
186+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
187+ if out, err := build.CombinedOutput(); err != nil {
188+ t.Fatalf("build x11probe: %v: %s", err, out)
189+ }
190+ return bin
191+}
192+
193+// startProbe starts x11probe on display, with X11 as its only display
194+// server, and stops it when the test ends.
195+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
196+ t.Helper()
197+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
198+ p.cmd = exec.Command(bin, args...)
199+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
200+ p.cmd.Stdout = p.out
201+ stderr := &lockedBuffer{}
202+ p.cmd.Stderr = stderr
203+ if err := p.cmd.Start(); err != nil {
204+ t.Fatal(err)
205+ }
206+ var exited sync.Once
207+ go func() { p.waited <- p.cmd.Wait() }()
208+ t.Cleanup(func() {
209+ exited.Do(func() {
210+ _ = p.cmd.Process.Kill()
211+ select {
212+ case <-p.waited:
213+ case <-time.After(2 * time.Second):
214+ }
215+ })
216+ if s := stderr.String(); s != "" {
217+ t.Logf("x11probe stderr: %s", s)
218+ }
219+ })
220+ return p
221+}
222+
223+func envWithout(keys ...string) []string {
224+ var env []string
225+next:
226+ for _, kv := range os.Environ() {
227+ for _, k := range keys {
228+ if strings.HasPrefix(kv, k+"=") {
229+ continue next
230+ }
231+ }
232+ env = append(env, kv)
233+ }
234+ return env
235+}
236+
237+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
238+func until(t *testing.T, what string, cond func() bool) {
239+ t.Helper()
240+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
241+ if time.Now().After(deadline) {
242+ t.Fatalf("timeout waiting for %s", what)
243+ }
244+ }
245+}
246+
247+// readState returns the current state file, or an empty state while it does
248+// not exist or is being replaced.
249+func readState(path string) statefile.State {
250+ st, err := statefile.Read(path)
251+ if err != nil {
252+ return statefile.State{}
253+ }
254+ return st
255+}
256+
257+// tileRect finds the rectangle of window id in the tiled columns.
258+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
259+ for _, ws := range st.Workspaces {
260+ for _, c := range ws.Columns {
261+ for _, w := range c.Windows {
262+ if w.ID == id {
263+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
264+ }
265+ }
266+ }
267+ }
268+ return image.Rectangle{}, false
269+}
270+
271+// greenShare is the fraction of pixels of r in the PNG at path that are
272+// clearly green; 0 when the image cannot be read yet.
273+func greenShare(path string, r image.Rectangle) float64 {
274+ f, err := os.Open(path)
275+ if err != nil {
276+ return 0
277+ }
278+ defer f.Close()
279+ img, err := png.Decode(f)
280+ if err != nil {
281+ return 0
282+ }
283+ r = r.Intersect(img.Bounds())
284+ if r.Empty() {
285+ return 0
286+ }
287+ n := 0
288+ for y := r.Min.Y; y < r.Max.Y; y++ {
289+ for x := r.Min.X; x < r.Max.X; x++ {
290+ cr, cg, cb, _ := img.At(x, y).RGBA()
291+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
292+ n++
293+ }
294+ }
295+ }
296+ return float64(n) / float64(r.Dx()*r.Dy())
297+}
298+
299+// magentaShare is the fraction of pixels of r in the PNG at path that are
300+// clearly magenta; 0 when the image cannot be read yet.
301+func magentaShare(path string, r image.Rectangle) float64 {
302+ f, err := os.Open(path)
303+ if err != nil {
304+ return 0
305+ }
306+ defer f.Close()
307+ img, err := png.Decode(f)
308+ if err != nil {
309+ return 0
310+ }
311+ r = r.Intersect(img.Bounds())
312+ if r.Empty() {
313+ return 0
314+ }
315+ n := 0
316+ for y := r.Min.Y; y < r.Max.Y; y++ {
317+ for x := r.Min.X; x < r.Max.X; x++ {
318+ cr, cg, cb, _ := img.At(x, y).RGBA()
319+ if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
320+ n++
321+ }
322+ }
323+ }
324+ return float64(n) / float64(r.Dx()*r.Dy())
325+}
326+
327+// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
328+// is drawn above the toplevel at its X position, is not a window in the
329+// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
330+func TestXwaylandOverride(t *testing.T) {
331+ e := startXwayland(t)
332+ // Probe unmaps the menu 3 s after mapping it; the first checks run well
333+ // inside that window.
334+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
335+ "-override-size", "120x80", "-override-for", "3s")
336+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
337+ shot := filepath.Join(e.shots, "latest.png")
338+ box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
339+ until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
340+
341+ var st statefile.State
342+ until(t, "focused X11Probe toplevel", func() bool {
343+ st = readState(e.statePath)
344+ return len(st.Windows) >= 1 && st.Window != nil
345+ })
346+ if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
347+ t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
348+ }
349+ id := st.Windows[0].ID
350+ if st.Window.ID != id {
351+ t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
352+ }
353+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
354+
355+ // The menu is above the toplevel: its box is magenta, not green.
356+ if g := greenShare(shot, box); g > 0.1 {
357+ t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
358+ }
359+ until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
360+ until(t, "menu gone from latest.png", func() bool {
361+ return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
362+ })
363+ if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
364+ t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
365+ }
366+}
367+
368+// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
369+// when core focuses another window, which is how toolkits close their menus.
370+func TestXwaylandOverrideDismiss(t *testing.T) {
371+ e := startXwayland(t)
372+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
373+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
374+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
375+ // A second X11 window takes the focus away from the menu owner.
376+ startProbe(t, e.bin, e.display, "-title", "other")
377+ until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
378+ box := image.Rect(100, 50, 220, 130)
379+ until(t, "menu gone from latest.png", func() bool {
380+ return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
381+ })
382+}
383+
384+// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
385+// and still not a window.
386+func TestXwaylandOverrideShape(t *testing.T) {
387+ e := startXwayland(t)
388+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
389+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
390+ until(t, "magenta tooltip in latest.png", func() bool {
391+ return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
392+ })
393+ until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
394+}
M internal/core/client.go +8 -0
@@ -70,6 +70,14 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
70 if _, w := c.screenOf(v.ID); w != nil {70 if _, w := c.screenOf(v.ID); w != nil {
71 w.SetModal(v.ID, v.Modal)71 w.SetModal(v.ID, v.Modal)
72 }72 }
73+ case ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped:
74+ changed, err := c.unmanagedEvent(ctx, v)
75+ if err != nil {
76+ return false, true
77+ }
78+ if !changed {
79+ return false, false
80+ }
73 case ports.WindowUnmapped:81 case ports.WindowUnmapped:
74 if c.unmapClient(ctx, v) != nil {82 if c.unmapClient(ctx, v) != nil {
75 return false, true83 return false, true
M internal/core/core.go +16 -5
@@ -103,9 +103,11 @@ type Core struct {
103 binds map[binding]Action103 binds map[binding]Action
104 pressed map[string]bool104 pressed map[string]bool
105 configures configures105 configures configures
106- pointer WindowID106+ // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window.
107- grab WindowID107+ placed map[WindowID]Rect
108- buttons map[uint32]bool108+ pointer WindowID
109+ grab WindowID
110+ buttons map[uint32]bool
109 // drag is the pointer drag in progress (drag.go); swallow holds the111 // drag is the pointer drag in progress (drag.go); swallow holds the
110 // buttons whose release no client must see. mods are the modifiers112 // buttons whose release no client must see. mods are the modifiers
111 // held, cmdMod the one keyboard.cmd names. lastButton is the latest113 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
@@ -144,7 +146,11 @@ type Core struct {
144 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.146 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
145 popups map[WindowID]*popupState147 popups map[WindowID]*popupState
146 popupOrder []WindowID148 popupOrder []WindowID
147- keyboard keyboard149+ // unmanaged are the mapped override-redirect windows, unmanagedOrder
150+ // stacks them, bottom to top (unmanaged.go).
151+ unmanaged map[WindowID]*unmanagedState
152+ unmanagedOrder []WindowID
153+ keyboard keyboard
148 // activity is when core last told wayland about user input154 // activity is when core last told wayland about user input
149 // (ports.UserActivity), sent at most once per ActivityInterval.155 // (ports.UserActivity), sent at most once per ActivityInterval.
150 activity time.Time156 activity time.Time
@@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
417 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")423 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
418 }424 }
419 // A placeholder screen holds windows until the first output arrives.425 // A placeholder screen holds windows until the first output arrives.
420- c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()}426+ c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, unmanaged: map[WindowID]*unmanagedState{}, unmanagedOrder: make([]WindowID, 0, 32), ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), placed: map[WindowID]Rect{}, ping: make(chan chan struct{}), out: newOutbox()}
421 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}427 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
422 if err := c.apply(cfg); err != nil {428 if err := c.apply(cfg); err != nil {
423 return nil, err429 return nil, err
@@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
994 }1000 }
995 sc := c.screens[i]1001 sc := c.screens[i]
996 lx, ly := x-float64(sc.x), y-float64(sc.y)1002 lx, ly := x-float64(sc.x), y-float64(sc.y)
1003+ // Unmanaged windows are over everything, popups included; a locked
1004+ // pointer keeps its focus (a mouse-look game).
1005+ if id, px, py := c.unmanagedAt(sc, lx, ly); id != 0 {
1006+ return id, px, py
1007+ }
997 // Layer popups are over everything; window popups are over the windows1008 // Layer popups are over everything; window popups are over the windows
998 // only, under the top and overlay layers (as drawn).1009 // only, under the top and overlay layers (as drawn).
999 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {1010 if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 {
M internal/core/core_test.go +4 -0
@@ -38,6 +38,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand {
38 for {38 for {
39 switch v := receive(t, ch); v.(type) {39 switch v := receive(t, ch); v.(type) {
40 case nil, ports.SetOutputs, ports.UserActivity:40 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)
41 default:45 default:
42 return v46 return v
43 }47 }
M internal/core/datadrag.go +1 -1
@@ -101,7 +101,7 @@ func dataDragHitChanged(ev ports.ClientEvent) bool {
101 case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,101 case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102 ports.WindowResized, ports.PopupRequest, ports.PopupMapped,102 ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103 ports.OutputPower, ports.WindowParent, ports.WindowModal,103 ports.OutputPower, ports.WindowParent, ports.WindowModal,
104- ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest,104+ ports.WindowUnmapped, ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped, ports.PointerWarp, ports.WindowMoveRequest,
105 ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:105 ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106 return true106 return true
107 default:107 default:
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 +12 -1
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
20 // layout is what is drawn; settled (same indexes) is where the20 // layout is what is drawn; settled (same indexes) is where the
21 // windows are going, and alone sizes the configures.21 // windows are going, and alone sizes the configures.
22 layout, settled := sc.shown, sc.settledLayout22 layout, settled := sc.shown, sc.settledLayout
23- scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)}23+ scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+len(c.unmanagedOrder)+1), Layers: shownLayers(sc)}
24 if !sc.mon.previewing() {24 if !sc.mon.previewing() {
25 // Tiles stop at the panels; the overview lays out previews over25 // Tiles stop at the panels; the overview lays out previews over
26 // the whole output.26 // the whole output.
@@ -116,8 +116,19 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
116 }116 }
117 c.configures.mark(v)117 c.configures.mark(v)
118 }118 }
119+ if !p.Hidden && p.Preview == 0 && c.windows.lookup(p.ID).client.Positioned {
120+ cr := c.clientRect(ps)
121+ at := Rect{X: sc.x + cr.X, Y: sc.y + cr.Y}
122+ if old, ok := c.placed[p.ID]; !ok || old != at {
123+ if err := c.command(ctx, ports.PlaceWindow{ID: p.ID, X: at.X, Y: at.Y}); err != nil {
124+ return ports.Scene{}, err
125+ }
126+ c.placed[p.ID] = at
127+ }
128+ }
119 }129 }
120 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)130 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
131+ scene.Windows = c.sceneUnmanaged(scene.Windows, sc)
121 if icon, ok := c.dataDragIconOn(i, sc); ok {132 if icon, ok := c.dataDragIconOn(i, sc); ok {
122 // The drag icon is over everything, popups and layers included.133 // The drag icon is over everything, popups and layers included.
123 scene.Windows = append(scene.Windows, icon)134 scene.Windows = append(scene.Windows, icon)
M internal/core/transition.go +1 -0
@@ -453,6 +453,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
453 // The window is unmapped: a toplevel that maps again keeps its ID and453 // The window is unmapped: a toplevel that maps again keeps its ID and
454 // must be configured from nothing (a leaving entry keeps no configure).454 // must be configured from nothing (a leaving entry keeps no configure).
455 c.configures.forget(v.ID)455 c.configures.forget(v.ID)
456+ delete(c.placed, v.ID)
456 sc, w := c.screenOf(v.ID)457 sc, w := c.screenOf(v.ID)
457 if sc == nil {458 if sc == nil {
458 return459 return
A internal/core/unmanaged.go +117 -0
@@ -0,0 +1,117 @@
1+package core
2+
3+import (
4+ "context"
5+ "slices"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+// unmanagedState is a mapped override-redirect X11 window: an absolute layout
11+// rect chosen by the client, no parent, never keyboard-focused. rect is in
12+// global logical coordinates.
13+type unmanagedState struct {
14+ id WindowID
15+ rect Rect
16+ noInput bool
17+}
18+
19+// unmanagedEvent applies an unmanaged window event. changed is true when the
20+// scene or the pointer target may differ.
21+func (c *Core) unmanagedEvent(ctx context.Context, ev ports.ClientEvent) (changed bool, err error) {
22+ switch v := ev.(type) {
23+ case ports.UnmanagedMapped:
24+ r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height}
25+ if u := c.unmanaged[v.ID]; u != nil {
26+ if u.rect == r && u.noInput == v.NoInput {
27+ return false, nil
28+ }
29+ u.rect, u.noInput = r, v.NoInput
30+ } else {
31+ c.unmanaged[v.ID] = &unmanagedState{id: v.ID, rect: r, noInput: v.NoInput}
32+ c.unmanagedOrder = append(c.unmanagedOrder, v.ID)
33+ }
34+ case ports.UnmanagedMoved:
35+ u := c.unmanaged[v.ID]
36+ r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height}
37+ if u == nil || u.rect == r {
38+ return false, nil
39+ }
40+ u.rect = r
41+ case ports.UnmanagedUnmapped:
42+ if c.unmanaged[v.ID] == nil {
43+ return false, nil
44+ }
45+ delete(c.unmanaged, v.ID)
46+ c.unmanagedOrder = slices.DeleteFunc(c.unmanagedOrder, func(id WindowID) bool { return id == v.ID })
47+ c.windows.drop(v.ID)
48+ if err := c.dataDragGone(ctx, v.ID); err != nil {
49+ return false, err
50+ }
51+ // rehit does nothing while a button grab or drag is held: do not
52+ // leave the pointer and grab on the dead window.
53+ if c.grab == v.ID {
54+ c.grab = 0
55+ }
56+ if c.pointer == v.ID {
57+ c.pointer = 0
58+ if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil {
59+ return false, err
60+ }
61+ }
62+ }
63+ // The window under a still pointer may have changed.
64+ return true, c.rehit(ctx)
65+}
66+
67+// unmanagedScreen is the screen showing u: the one containing its origin, or
68+// the one the pointer clamp would pick when the origin is in a hole.
69+func (c *Core) unmanagedScreen(u *unmanagedState) *screen {
70+ x, y := float64(u.rect.X), float64(u.rect.Y)
71+ i := c.screenAt(x, y)
72+ if i < 0 {
73+ cx, cy := c.clampPointer(x, y, x, y)
74+ i = c.screenAt(cx, cy)
75+ }
76+ if i < 0 {
77+ return nil
78+ }
79+ return c.screens[i]
80+}
81+
82+// sceneUnmanaged appends the unmanaged windows shown on sc, bottom to top,
83+// drawn as popups over the layers. They are hidden while sc previews.
84+func (c *Core) sceneUnmanaged(out []ports.SceneWindow, sc *screen) []ports.SceneWindow {
85+ if len(c.unmanagedOrder) == 0 || sc.mon.previewing() {
86+ return out
87+ }
88+ for _, id := range c.unmanagedOrder {
89+ u := c.unmanaged[id]
90+ if u == nil || c.unmanagedScreen(u) != sc {
91+ continue
92+ }
93+ r := u.rect
94+ r.X, r.Y = r.X-sc.x, r.Y-sc.y
95+ out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: true})
96+ }
97+ return out
98+}
99+
100+// unmanagedAt is the topmost unmanaged window taking the pointer at the
101+// screen-local point. None while the pointer is locked or the screen previews.
102+func (c *Core) unmanagedAt(sc *screen, lx, ly float64) (WindowID, float64, float64) {
103+ if len(c.unmanagedOrder) == 0 || c.constraint.Mode == ports.ConstraintLock || sc.mon.previewing() {
104+ return 0, 0, 0
105+ }
106+ for i := len(c.unmanagedOrder) - 1; i >= 0; i-- {
107+ u := c.unmanaged[c.unmanagedOrder[i]]
108+ if u == nil || u.noInput || c.unmanagedScreen(u) != sc {
109+ continue
110+ }
111+ x, y := lx-float64(u.rect.X-sc.x), ly-float64(u.rect.Y-sc.y)
112+ if x >= 0 && x < float64(u.rect.W) && y >= 0 && y < float64(u.rect.H) && c.acceptsInput(u.id, x, y) {
113+ return u.id, x, y
114+ }
115+ }
116+ return 0, 0, 0
117+}
A internal/core/unmanaged_test.go +145 -0
@@ -0,0 +1,145 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func unmanagedScene(t *testing.T, c *Core, i int) ports.Scene {
11+ t.Helper()
12+ s, err := c.sceneFor(context.Background(), i, c.screens[i], nil, nil)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return s
17+}
18+
19+func mapUnmanaged(t *testing.T, c *Core, id WindowID, x, y, w, h int, noInput bool) {
20+ t.Helper()
21+ c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: id, X: x, Y: y, Width: w, Height: h, NoInput: noInput})
22+}
23+
24+func TestUnmanagedScene(t *testing.T) {
25+ c := publishRig(t, 2)
26+ mapUnmanaged(t, c, 100, 310, 20, 50, 40, false)
27+ mapUnmanaged(t, c, 101, 5, 5, 10, 10, true)
28+ b := unmanagedScene(t, c, 1)
29+ last := b.Windows[len(b.Windows)-1]
30+ if last.ID != 100 || last.Rect != (Rect{X: 10, Y: 20, W: 50, H: 40}) || !last.Popup || !last.OverLayers {
31+ t.Fatalf("screen B top window %+v", last)
32+ }
33+ a := unmanagedScene(t, c, 0)
34+ if got := a.Windows[len(a.Windows)-1]; got.ID != 101 || got.Rect.X != 5 {
35+ t.Fatalf("screen A top window %+v", got)
36+ }
37+ c.clientEvent(context.Background(), ports.UnmanagedMoved{ID: 100, X: 20, Y: 20, Width: 50, Height: 40})
38+ if got := unmanagedScene(t, c, 0).Windows; got[len(got)-2].ID != 100 || got[len(got)-2].Rect.X != 20 {
39+ t.Fatalf("moved window not on screen A: %+v", got)
40+ }
41+ c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100})
42+ c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 101})
43+ if len(c.unmanagedOrder) != 0 || len(c.unmanaged) != 0 {
44+ t.Fatal("state left after unmap")
45+ }
46+}
47+
48+func TestUnmanagedHiddenWhilePreviewing(t *testing.T) {
49+ c := publishRig(t, 1)
50+ mapUnmanaged(t, c, 100, 10, 10, 50, 40, false)
51+ c.cur().mon.ov.open = true
52+ if !c.cur().mon.previewing() {
53+ t.Fatal("setup: not previewing")
54+ }
55+ for _, w := range unmanagedScene(t, c, 0).Windows {
56+ if w.ID == 100 {
57+ t.Fatal("unmanaged window drawn in the overview")
58+ }
59+ }
60+ if id, _, _ := c.hit(20, 20); id == 100 {
61+ t.Fatal("unmanaged window hit in the overview")
62+ }
63+}
64+
65+func TestUnmanagedHit(t *testing.T) {
66+ c := publishRig(t, 2)
67+ mapUnmanaged(t, c, 100, 310, 20, 50, 40, false)
68+ mapUnmanaged(t, c, 101, 320, 30, 10, 10, false)
69+ mapUnmanaged(t, c, 102, 340, 30, 10, 10, true)
70+ if id, x, y := c.hit(325, 35); id != 101 || x != 5 || y != 5 {
71+ t.Fatalf("topmost: %d %v %v", id, x, y)
72+ }
73+ if id, _, _ := c.hit(345, 35); id != 100 {
74+ t.Fatalf("noInput must be skipped: %d", id)
75+ }
76+ c.windows.setRegion(ports.InputRegionChanged{ID: 100, Rects: []Rect{{X: 0, Y: 0, W: 5, H: 5}}})
77+ if id, _, _ := c.hit(345, 35); id == 100 {
78+ t.Fatal("outside the input region")
79+ }
80+ c.constraint.Mode = ports.ConstraintLock
81+ if id, _, _ := c.hit(325, 35); id == 101 {
82+ t.Fatal("locked pointer must not move to an unmanaged window")
83+ }
84+ c.constraint.Mode = 0
85+ c.security.Protected = true
86+ if id, _, _ := c.hit(325, 35); id == 101 {
87+ t.Fatal("protected session hit an unmanaged window")
88+ }
89+}
90+
91+func unmanagedRig(t testing.TB, n int) *Core {
92+ c := publishRig(t, 1)
93+ for i := range n {
94+ c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: WindowID(100 + i), X: 10 * i, Y: 10, Width: 30, Height: 30})
95+ }
96+ return c
97+}
98+
99+func publishOnce(t testing.TB, c *Core) {
100+ if err := c.publish(context.Background()); err != nil {
101+ t.Fatal(err)
102+ }
103+ <-c.ch.Scenes
104+}
105+
106+// TestUnmanagedPublishAllocations: three unmanaged windows cost no more than
107+// none (the scene window slice is presized for them).
108+func TestUnmanagedPublishAllocations(t *testing.T) {
109+ base, with := unmanagedRig(t, 0), unmanagedRig(t, 3)
110+ publishOnce(t, base)
111+ publishOnce(t, with)
112+ a := testing.AllocsPerRun(50, func() { publishOnce(t, base) })
113+ b := testing.AllocsPerRun(50, func() { publishOnce(t, with) })
114+ if b > a {
115+ t.Fatalf("publish allocs with unmanaged = %v, without = %v", b, a)
116+ }
117+}
118+
119+func TestUnmanagedHitAllocations(t *testing.T) {
120+ c := unmanagedRig(t, 3)
121+ if n := testing.AllocsPerRun(100, func() { c.hit(25, 20); c.hit(250, 150) }); n != 0 {
122+ t.Fatalf("hit allocs = %v", n)
123+ }
124+}
125+
126+func TestUnmanagedUnmapDuringGrabClearsPointer(t *testing.T) {
127+ c := publishRig(t, 1)
128+ mapUnmanaged(t, c, 100, 10, 10, 50, 40, false)
129+ cmds := make(chan ports.ClientCommand, 64)
130+ c.ch.Commands = cmds
131+ c.pointer, c.grab = 100, 100
132+ c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100})
133+ if c.pointer == 100 || c.grab != 0 {
134+ t.Fatalf("pointer %d grab %d left on the dead window", c.pointer, c.grab)
135+ }
136+ var focus bool
137+ for len(cmds) > 0 {
138+ if f, ok := (<-cmds).(ports.PointerFocus); ok && f == (ports.PointerFocus{}) {
139+ focus = true
140+ }
141+ }
142+ if !focus {
143+ t.Fatal("pointer focus not reset")
144+ }
145+}
M internal/ports/client_commands.go +10 -0
@@ -47,6 +47,16 @@ type ConfigureWindow struct {
47 47
48 func (ConfigureWindow) clientCommand() {}48 func (ConfigureWindow) clientCommand() {}
49 49
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+
50 // OutputPlacement is one output in the global layout. X, Y, Width and60 // OutputPlacement is one output in the global layout. X, Y, Width and
51 // Height are logical (after Transform: they swap for 90/270);61 // Height are logical (after Transform: they swap for 90/270);
52 // physical = logical × Scale. Info.Width and Info.Height are the physical62 // physical = logical × Scale. Info.Width and Info.Height are the physical
M internal/ports/client_events.go +28 -0
@@ -20,6 +20,9 @@ type WindowMapped struct {
20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over20 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
21 // it.21 // it.
22 Parent WindowID22 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
23 // Remap is set when the toplevel mapped before and was unmapped (a tray26 // Remap is set when the toplevel mapped before and was unmapped (a tray
24 // app restored): window rules only place its first map.27 // app restored): window rules only place its first map.
25 Remap bool28 Remap bool
@@ -49,6 +52,31 @@ type WindowUnmapped struct{ ID WindowID }
49 52
50 func (WindowUnmapped) clientEvent() {}53 func (WindowUnmapped) clientEvent() {}
51 54
55+// UnmanagedMapped carries wayland → core that an override-redirect X11 window
56+// shows. The rect is its place in the layout (logical coordinates). NoInput
57+// windows (tooltips, drag icons, notifications) never take the pointer.
58+type UnmanagedMapped struct {
59+ ID WindowID
60+ X, Y, Width, Height int
61+ NoInput bool
62+}
63+
64+func (UnmanagedMapped) clientEvent() {}
65+
66+// UnmanagedMoved carries wayland → core a move and/or resize of a mapped
67+// unmanaged window.
68+type UnmanagedMoved struct {
69+ ID WindowID
70+ X, Y, Width, Height int
71+}
72+
73+func (UnmanagedMoved) clientEvent() {}
74+
75+// UnmanagedUnmapped carries wayland → core that an unmanaged window is gone.
76+type UnmanagedUnmapped struct{ ID WindowID }
77+
78+func (UnmanagedUnmapped) clientEvent() {}
79+
52 // ShortcutsInhibit carries wayland → core that a window asks for (Active)80 // ShortcutsInhibit carries wayland → core that a window asks for (Active)
53 // or stops asking for all keys, compositor binds included, while it has81 // or stops asking for all keys, compositor binds included, while it has
54 // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).82 // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
M internal/ports/config.go +17 -3
@@ -101,9 +101,8 @@ type Config struct {
101 Command []string101 Command []string
102 AutoOpen string102 AutoOpen string
103 }103 }
104- // Xwayland is the xwayland-satellite binary serving X11 clients; empty104+ // Xwayland selects the X11 launcher.
105- // disables X11.105+ Xwayland XwaylandConfig
106- Xwayland string
107 // Startup are commands run once when the session starts, in order.106 // Startup are commands run once when the session starts, in order.
108 // One startup line may hold several, split by SplitCommands.107 // One startup line may hold several, split by SplitCommands.
109 Startup [][]string108 Startup [][]string
@@ -298,3 +297,18 @@ type WindowRule struct {
298 // (1 to 16; 0 leaves it automatic).297 // (1 to 16; 0 leaves it automatic).
299 Cells int298 Cells int
300 }299 }
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