detached 2afef1f

Merge 2e202e682d24faec15dc9bec9b521cc7ef82742f into 6d4359dbea29ee59b995de9e90138af5f5b85898

commit
2afef1f2b25b51130d03c21b9c142fb9405b8295
author
ermite · · 2026-10-22 16:35 UTC
merge of
6d4359d + 2e202e6 · diff against the first parent
signature
unsigned
101 files · +17822 -150
M .mockery.yml +25 -0
@@ -87,6 +87,11 @@ packages:
8787 dir: internal/adapters/wayland
8888 pkgname: wayland
8989 filename: acquire_poller_mock_test.go
90+ xwmSelection:
91+ config:
92+ dir: internal/adapters/wayland
93+ pkgname: wayland
94+ filename: xwm_selection_mock_test.go
9095 syncobjDevice:
9196 config:
9297 dir: internal/adapters/wayland
@@ -187,3 +192,23 @@ packages:
187192 dir: internal/adapters/screencast
188193 pkgname: screencast
189194 filename: pipewire_opener_mock_test.go
195+
196+ github.com/bnema/neferwl/internal/adapters/xwayland:
197+ interfaces:
198+ starter:
199+ config:
200+ dir: internal/adapters/xwayland
201+ pkgname: xwayland
202+ filename: starter_mock_test.go
203+ github.com/bnema/neferwl/internal/adapters/wayland/xwm:
204+ interfaces:
205+ host:
206+ config:
207+ dir: internal/adapters/wayland/xwm
208+ pkgname: xwm
209+ filename: host_mock_test.go
210+ fdSource:
211+ config:
212+ dir: internal/adapters/wayland/xwm
213+ pkgname: xwm
214+ filename: fdsource_mock_test.go
M Makefile +9 -2
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
2626 CGO_ENABLED=0 go test ./...
2727 vet:
2828 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
2930 race:
3031 # cgo is enabled only for the race test binary.
3132 CGO_ENABLED=1 go test -race ./...
@@ -69,7 +70,8 @@ adapter-imports:
6970 [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; }
7071 # Fast, mandatory guards for tiled content publications and SHM copies.
7172 perf-check:
72- CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=1
73+ CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast ./internal/adapters/wayland/xwm -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing|TestTableLookupAllocations|TestTableInsertRemoveAllocations|TestPropertyUnchangedAllocations|TestDispatchAllocations|TestConfigureFlushAllocations|TestFocusAllocations|TestSpaceAllocations|TestUnmanagedGeometryAllocations|TestFocusInUnmanagedAllocations|TestUnmanagedPublishAllocations|TestUnmanagedHitAllocations|TestOverrideMovedUnchangedAllocations|TestFullscreenConfigureAllocations|TestKeyboardGrabSyncAllocations|TestSelectionCopyAllocations|TestSelectionCopyAllocationsWaylandToX|TestSelectionINCRChunkAllocations|TestXSettingsNoChangeAllocations)$$' -count=1
74+ CGO_ENABLED=0 go test -tags xturbo_poison ./internal/adapters/wayland/xwm -count=1
7375 check: vet test arch fakes-check log-check perf-check
7476
7577 # On-demand SDR/HDR colour check with examples/testpattern on a headless
@@ -77,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
7779 color-check:
7880 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
7981
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
8087 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
8188 # come from the module proxy in prepare(); the build itself runs offline.
8289 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
M README.md +1 -1
@@ -149,7 +149,7 @@ See [Configuration](docs/config.md) for all keys, actions, bindings and automati
149149 - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
150150 - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
151151 - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
152-- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.
152+- Steam and other X11 clients run through xwayland-satellite, or through the experimental built-in window manager (`xwayland = builtin`; menus, popups and tooltips, fullscreen games with keyboard grab; text clipboard and primary selection in both directions, XSETTINGS and a themed root cursor). Wine can run natively on Wayland.
153153
154154 ### Windows and workspaces
155155
M docs/config.md +9 -3
@@ -1,6 +1,6 @@
11 # Configuration
22
3-NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup` and `xwayland`, which apply at the next start.
3+NeferWL reads `~/.config/neferwl/config` (or `$XDG_CONFIG_HOME/neferwl/config`). Changes apply when you save, except `startup`, `xwayland` and `xwayland.xsettings`, which apply at the next start.
44
55 ```sh
66 neferwl --config <path> # use another file
@@ -27,7 +27,8 @@ See [examples/config](../examples/config) for every key with its default.
2727 | `terminal` | `$TERMINAL` or `foot` | Terminal command |
2828 | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) |
2929 | `startup` | none | Commands run at session start, separated by `;` |
30-| `xwayland` | `xwayland-satellite` | X11 support; `off` disables it |
30+| `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path |
31+| `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize` and `Xft/DPI` to X11 apps through XSETTINGS (restart required); `off` leaves them to the apps |
3132 | `background` | `#111111` | Background color |
3233 | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps |
3334 | `border.width` | `2` | Border width in pixels; `0` hides borders |
@@ -103,6 +104,8 @@ destination output, even if that output has no windows. Same-screen workspace
103104 switches and mouse focus do not trigger a flash.
104105 It runs when `animations = on`.
105106
107+`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`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, the DPI of the first output (96 times its scale) through XSETTINGS (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected).
108+
106109 ## Automatic terminal
107110
108111 The command comes from `terminal`, then `$TERMINAL`, then `foot`.
@@ -144,7 +147,7 @@ Rules place an app's windows when they first open.
144147 | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 |
145148
146149 ```ini
147-rule.games.app-id = steam_app_.*
150+rule.games.app-id = steam_app_\d+
148151 rule.games.workspace = 3
149152 rule.chat.app-id = discord|org\.telegram\..*
150153 rule.chat.workspace = chat
@@ -155,6 +158,9 @@ rule.calc.floating = on
155158
156159 - Every matching rule applies, in file order; later rules win.
157160 - A rule never moves your focus or view.
161+- Proton games (X11) use `steam_app_<id>` as app ID, taken from `WM_CLASS`.
162+- An X11 window that opens exactly on one output's area opens on that output, unless a rule sets `monitor`.
163+- A rule can float a Proton game: `rule.games.app-id = steam_app_\d+` with `rule.games.floating = on`. Without a rule, X11 games tile until they ask for fullscreen.
158164 - To find an app ID: `neferwl state | jq '.windows[].app_id'`.
159165
160166 ## Output placement
M docs/desktop.md +12 -1
@@ -52,12 +52,23 @@ Candidate popups are not drawn yet.
5252
5353 ## X11 apps
5454
55-X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
55+By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself.
5656
5757 ```text
5858 xwayland = off # no X11 support
5959 ```
6060
61+With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI (96 times the scale of the first output) through XSETTINGS (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses.
62+
63+Games with `builtin`:
64+
65+- **Fullscreen:** an X11 window can ask for fullscreen (`_NET_WM_STATE_FULLSCREEN`) and leave it again; `toggle-fullscreen` works too. A request within 1 second of the window mapping is ignored, so a launcher that remembers a fullscreen size does not cover the windows it opens next. The app can ask again later.
66+- **Output:** a window that opens exactly at one output's area opens on that output, and its fullscreen fills it.
67+- **Keyboard grab:** an X11 app that grabs the keyboard inhibits the compositor binds only while it is focused and fullscreen; a windowed app cannot lock the binds. There is no escape bind, as with Wayland shortcut inhibitors; leave by unfullscreening from the app or switching away with the mouse.
68+- **Pointer:** mouse-look works through relative pointer and pointer constraints.
69+- **App ID:** Proton windows are named `steam_app_<id>`; match them with a [window rule](config.md#window-rules).
70+- **Bypass hint:** `_NET_WM_BYPASS_COMPOSITOR` is read but changes nothing; direct scanout is chosen from the window state.
71+
6172 ## Session locking
6273
6374 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 +25 -0
@@ -62,6 +62,31 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本
6262
6363 `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit.
6464
65+## X11 check
66+
67+`make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection.
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`, `-at X,Y`) 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`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. 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+- a fullscreen request 1.5 s after the map fills the output (all green, no gap) and `Super+Shift+f` leaves it; the client can also leave it itself; a request at the map stays tiled (the 1 s grace);
79+- with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it;
80+- on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there;
81+- clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`;
82+- XSETTINGS: serial, `Xft/DPI=98304` (scale 1) or `196608` (`output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner;
83+- root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`);
84+- a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class.
85+
86+Not covered headless: relative pointer lock (needs a real pointer device) and Xwayland output hotplug (`--size` is fixed); these are unit-tested in the `wayland` adapter.
87+
88+Run it by hand with `go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v`.
89+
6590 ## Colour check
6691
6792 `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`.
M go.mod +4 -3
@@ -3,10 +3,11 @@ module github.com/bnema/neferwl
33 go 1.27
44
55 require (
6+ bnema.dev/xturbo v0.1.0
67 github.com/bnema/go-wayland-bindings v0.1.0
78 github.com/bnema/kvconf v0.1.2
8- github.com/bnema/purego v0.13.0-bnema.1
9- github.com/bnema/purego-libwayland v0.11.0
9+ github.com/bnema/purego v0.13.0-bnema.2
10+ github.com/bnema/purego-libwayland v0.12.0
1011 github.com/bnema/purego-pipewire v0.2.0
1112 github.com/bnema/purego-vulkan v0.6.0
1213 github.com/bnema/wlturbo v0.5.0
@@ -16,7 +17,7 @@ require (
1617 github.com/rs/zerolog v1.35.1
1718 github.com/stretchr/testify v1.12.1
1819 golang.org/x/image v0.46.0
19- golang.org/x/sys v0.48.0
20+ golang.org/x/sys v0.49.0
2021 golang.org/x/term v0.46.0
2122 )
2223
M go.sum +8 -6 generated file: collapsed
M internal/adapters/config/config.go +18 -4
@@ -167,7 +167,7 @@ func Defaults() ports.Config {
167167 // Hint mode is experimental: off unless the config turns it on.
168168 c.Hint.Enable = false
169169 c.Hint.TextClick = ports.ModCtrl | ports.ModShift
170- c.Xwayland = "xwayland-satellite"
170+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}
171171 c.Background.Color = "#111111"
172172 c.Floating.Dim = 0.3
173173 c.Stash.Width = 80
@@ -724,13 +724,27 @@ func set(c *ports.Config, key, v string) error {
724724 }
725725 c.Terminal.AutoOpen = v
726726 case "xwayland":
727+ xs := c.Xwayland.XSettings // independent of the backend
727728 switch {
728729 case v == "off":
729- c.Xwayland = ""
730+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: xs}
731+ case v == "satellite":
732+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: xs}
733+ case v == "builtin":
734+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: xs}
730735 case v == "" || strings.ContainsAny(v, " \t"):
731- return fmt.Errorf("must be off or the xwayland-satellite program")
736+ return fmt.Errorf("must be off, satellite, builtin or a satellite program")
732737 default:
733- c.Xwayland = v
738+ c.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: v, XSettings: xs}
739+ }
740+ case "xwayland.xsettings":
741+ switch v {
742+ case "on":
743+ c.Xwayland.XSettings = true
744+ case "off":
745+ c.Xwayland.XSettings = false
746+ default:
747+ return fmt.Errorf("must be on or off")
734748 }
735749 case "background":
736750 if !color.MatchString(v) {
M internal/adapters/config/config_test.go +36 -4
@@ -340,13 +340,45 @@ func TestWorkspacePrevNextAction(t *testing.T) {
340340 }
341341
342342 func TestXwayland(t *testing.T) {
343- if Defaults().Xwayland != "xwayland-satellite" {
343+ if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}) {
344344 t.Fatal(Defaults().Xwayland)
345345 }
346- if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
347- t.Fatal(c.Xwayland, w)
346+ for _, tt := range []struct {
347+ value string
348+ want ports.XwaylandConfig
349+ }{
350+ {"off", ports.XwaylandConfig{Backend: ports.XwaylandOff, XSettings: true}},
351+ {"satellite", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}},
352+ {"builtin", ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}},
353+ {"/opt/xwls", ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "/opt/xwls", XSettings: true}},
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+ })
361+ }
362+}
363+
364+func TestXwaylandXSettings(t *testing.T) {
365+ for _, tt := range []struct {
366+ src string
367+ want bool
368+ }{
369+ {"", true},
370+ {"xwayland.xsettings = on", true},
371+ {"xwayland.xsettings = off", false},
372+ {"xwayland.xsettings = off\nxwayland = builtin", false},
373+ {"xwayland = builtin\nxwayland.xsettings = off", false},
374+ } {
375+ c, w := parseString(t, tt.src)
376+ if c.Xwayland.XSettings != tt.want || len(w) != 0 {
377+ t.Fatal(tt.src, c.Xwayland, w)
378+ }
348379 }
349- if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
380+ c, w := parseString(t, "xwayland.xsettings = maybe")
381+ if !c.Xwayland.XSettings || len(w) != 1 || !strings.Contains(w[0].Msg, "must be on or off") {
350382 t.Fatal(c.Xwayland, w)
351383 }
352384 }
M internal/adapters/vulkan/capture_test.go +3 -0
@@ -358,6 +358,9 @@ func openFDs(t *testing.T) int {
358358 // slot keeps its sync file for every lease. No mock stands in the measured
359359 // closure.
360360 func TestCaptureCycleAllocations(t *testing.T) {
361+ if raceEnabled {
362+ t.Skip("allocation budgets are not stable under the race runtime")
363+ }
361364 for _, tc := range []struct {
362365 name string
363366 new func(*testing.T) *Renderer
A internal/adapters/vulkan/norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vulkan
4+
5+const raceEnabled = false
A internal/adapters/vulkan/race_test.go +8 -0
@@ -0,0 +1,8 @@
1+//go:build race
2+
3+package vulkan
4+
5+// raceEnabled: sync.Pool drops items at random under the race runtime, so the
6+// allocation budgets of calls that go through purego cannot hold. perf-check
7+// runs them without it.
8+const raceEnabled = true
M internal/adapters/vulkan/scene_perf_test.go +6 -0
@@ -16,6 +16,9 @@ import (
1616 // A new scene Seq with one of two windows moved is a partial redraw seeded by
1717 // sceneDelta and allocates nothing either.
1818 func TestRenderSteadyStateAllocations(t *testing.T) {
19+ if raceEnabled {
20+ t.Skip("allocation budgets are not stable under the race runtime")
21+ }
1922 for _, tc := range []struct {
2023 name string
2124 transform ports.BufferTransform
@@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) {
145148 // keeps one alloc of headroom for drivers whose import path differs.
146149 // Skipped without a GPU or /dev/udmabuf.
147150 func TestRenderSteadyStateDMABufAllocations(t *testing.T) {
151+ if raceEnabled {
152+ t.Skip("allocation budgets are not stable under the race runtime")
153+ }
148154 r, err := New(80, 32)
149155 if err != nil {
150156 t.Skipf("Vulkan unavailable: %v", err)
M internal/adapters/wayland/activation.go +3 -3
@@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *
6666 }
6767 var w *window
6868 if reason == "" {
69- if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped {
69+ if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped {
7070 reason = "not_toplevel"
7171 }
7272 }
@@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) {
116116
117117 // focusClient is the client with the keyboard focus.
118118 func (s *Server) focusClient() server.Client {
119- if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() {
120- return w.xdg.resource.Client()
119+ if w := s.windows[s.seat.focused]; w != nil && w.alive() {
120+ return w.client()
121121 }
122122 if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() {
123123 return l.resource.Client()
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
@@ -246,3 +246,12 @@ func TestCommandForwardAllocations(t *testing.T) {
246246 t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs)
247247 }
248248 }
249+
250+// A settled grab (none, or unchanged) is re-checked at every focus and
251+// configure without allocating.
252+func TestKeyboardGrabSyncAllocations(t *testing.T) {
253+ s := &Server{windows: map[ports.WindowID]*window{}}
254+ if n := testing.AllocsPerRun(100, s.syncKeyboardGrab); n != 0 {
255+ t.Fatalf("%v allocs", n)
256+ }
257+}
M internal/adapters/wayland/clipboard.go +5 -0
@@ -37,6 +37,8 @@ type clipSource struct {
3737 // used: the source was set, rejected or dragged; it cannot be set
3838 // again and takes no more MIME types.
3939 used bool
40+ // x11: the offer is an X client's selection (xSource), not a Wayland one.
41+ x11 bool
4042 // res is the wl_data_source (nil for the other protocols); actions are
4143 // its set_actions, for drag and drop only (dnd.go).
4244 res *wayland.DataSource
@@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
105107 if src != nil && src != s.selections[kind] && src.alive() {
106108 src.cancel()
107109 }
110+ s.exposeToX(kind, nil) // X clients must not read a protected selection
108111 return
109112 }
110113 old := s.selections[kind]
@@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) {
116119 old.cancel()
117120 }
118121 s.announceAll(kind)
122+ s.exposeToX(kind, src)
119123 }
120124
121125 // announceAll sends the selection of kind to the focused client and to
@@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) {
187191 if s.selections[kind] == src {
188192 s.selections[kind] = nil
189193 s.announceAll(kind)
194+ s.exposeToX(kind, nil)
190195 }
191196 }
192197 }
A internal/adapters/wayland/clipboard_xwm.go +93 -0
@@ -0,0 +1,93 @@
1+package wayland
2+
3+import (
4+ "slices"
5+
6+ "golang.org/x/sys/unix"
7+)
8+
9+// The text clipboard bridge between Wayland and the built-in XWM. An X
10+// client's selection becomes a clipSource like any other (xSource); a Wayland
11+// selection is exposed to X through exposeToX. The X protocol side lives in
12+// the xwm package; nothing of it reaches core or ports.
13+
14+// xwmSelection is the XWM as the clipboard sees it.
15+type xwmSelection interface {
16+ // WaylandSelection: a Wayland client owns kind with these MIME types;
17+ // nil mimes: none does (or it is protected).
18+ WaylandSelection(kind int, mimes []string)
19+ // SelectionReceive hands the XWM the write end of a reader's pipe (it
20+ // closes it) to fill with the X selection as mime.
21+ SelectionReceive(kind int, mime string, fd int)
22+}
23+
24+// xSource is the clipSource of an X-owned selection: no Wayland client, the
25+// data comes from the XWM. It is valid while it is the current X source.
26+func (s *Server) xSource(kind int, mimes []string) *clipSource {
27+ src := &clipSource{mimes: slices.Clone(mimes), used: true, x11: true}
28+ src.alive = func() bool { return s.xsel[kind] == src }
29+ src.cancel = func() {}
30+ src.send = func(mime string, fd int) {
31+ x := s.xwmSel
32+ if x == nil {
33+ return
34+ }
35+ // receive closes its fd after send: the XWM gets its own.
36+ dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
37+ if err != nil {
38+ return
39+ }
40+ x.SelectionReceive(kind, mime, dup)
41+ }
42+ return src
43+}
44+
45+// setXSelection is the XWM's report: an X client owns kind (mimes), or none
46+// does. A Wayland owner that already replaced the X one is never clobbered.
47+func (s *Server) setXSelection(kind int, mimes []string) {
48+ if len(mimes) == 0 {
49+ cur := s.xsel[kind]
50+ s.xsel[kind] = nil
51+ if cur != nil && s.selections[kind] == cur {
52+ s.setSelection(kind, nil)
53+ }
54+ return
55+ }
56+ src := s.xSource(kind, mimes)
57+ s.xsel[kind] = src
58+ s.setSelection(kind, src)
59+}
60+
61+// exposeToX tells the XWM what Wayland offers. An X source is not exposed
62+// back (it is X's own); nil withdraws.
63+func (s *Server) exposeToX(kind int, src *clipSource) {
64+ if s.xwmSel == nil {
65+ return
66+ }
67+ if src == nil || src.x11 {
68+ s.xwmSel.WaylandSelection(kind, nil)
69+ return
70+ }
71+ s.xwmSel.WaylandSelection(kind, src.mimes)
72+}
73+
74+// xSelectionRead returns a non-blocking read end of the Wayland selection
75+// kind as mime, or -1.
76+func (s *Server) xSelectionRead(kind int, mime string) int {
77+ src := s.selections[kind]
78+ if src == nil || src.x11 || s.protected() || !src.alive() || !slices.Contains(src.mimes, mime) {
79+ return -1
80+ }
81+ var p [2]int
82+ if unix.Pipe2(p[:], unix.O_CLOEXEC) != nil {
83+ return -1
84+ }
85+ if unix.SetNonblock(p[0], true) != nil {
86+ unix.Close(p[0])
87+ unix.Close(p[1])
88+ return -1
89+ }
90+ src.send(mime, p[1])
91+ unix.Close(p[1])
92+ return p[0]
93+}
A internal/adapters/wayland/clipboard_xwm_test.go +147 -0
@@ -0,0 +1,147 @@
1+package wayland
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/go-wayland-bindings/server/wayland"
8+ "github.com/stretchr/testify/mock"
9+ "golang.org/x/sys/unix"
10+)
11+
12+func onDisplay(t *testing.T, s *Server, f func()) {
13+ t.Helper()
14+ if !s.display.Do(f) {
15+ t.Fatal("display stopped")
16+ }
17+}
18+
19+// receiveVia asks the offer for mime into a fresh pipe and returns its read end.
20+func receiveVia(t *testing.T, cc *clipClient, offer uint32, mime string) int {
21+ t.Helper()
22+ var fds [2]int
23+ if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
24+ t.Fatal(err)
25+ }
26+ t.Cleanup(func() { unix.Close(fds[0]) })
27+ if err := wireRequest(cc.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, mime); err != nil {
28+ t.Fatal(err)
29+ }
30+ _ = unix.Close(fds[1])
31+ if err := cc.c.Roundtrip(); err != nil {
32+ t.Fatal(err)
33+ }
34+ return fds[0]
35+}
36+
37+// An X selection reaches the focused client and not the others, is replaced
38+// when a Wayland client copies (and is then not read back from X), and is
39+// never read while the session is protected.
40+func TestXSelectionReachesWaylandClients(t *testing.T) {
41+ s, events, commands, dir := keyboardServer(t)
42+ state := installSecurity(t, s)
43+ xm := newMockxwmSelection(t)
44+ onDisplay(t, s, func() { s.xwmSel = xm })
45+ a := newClipClient(t, s, dir, events)
46+ b := newClipClient(t, s, dir, events)
47+ a.focus(t, commands)
48+
49+ // X owns CLIPBOARD: the focused client gets an offer, the other none.
50+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe()
51+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING"}) })
52+ offer, ok := a.selection(t)
53+ if !ok || offer == 0 {
54+ t.Fatalf("focused client got no X offer: %d %v", offer, ok)
55+ }
56+ if id, ok := b.selection(t); ok {
57+ t.Fatalf("unfocused client got %d", id)
58+ }
59+
60+ // Reading reaches the XWM with a pipe it owns.
61+ got := make(chan string, 1)
62+ xm.EXPECT().SelectionReceive(selClipboard, "UTF8_STRING", mock.Anything).Run(func(_ int, mime string, fd int) {
63+ _, _ = unix.Write(fd, []byte("from x"))
64+ _ = unix.Close(fd)
65+ got <- mime
66+ }).Once()
67+ r := receiveVia(t, a, offer, "UTF8_STRING")
68+ select {
69+ case <-got:
70+ case <-time.After(2 * time.Second):
71+ t.Fatal("XWM never asked to receive")
72+ }
73+ buf := make([]byte, 16)
74+ n, err := unix.Read(r, buf)
75+ if err != nil || string(buf[:n]) != "from x" {
76+ t.Fatalf("pipe: %q %v", buf[:n], err)
77+ }
78+
79+ // Focus follows: b gets the X offer too (focus replay).
80+ if id := b.focus(t, commands); id == 0 {
81+ t.Fatal("no X offer on focus")
82+ }
83+
84+ // A Wayland copy replaces it and is exposed to X.
85+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
86+ b.copy(t)
87+ if id, ok := b.selection(t); !ok || id == 0 {
88+ t.Fatal("copy gave no offer")
89+ }
90+ // X clearing afterwards never clobbers the Wayland owner.
91+ onDisplay(t, s, func() { s.setXSelection(selClipboard, nil) })
92+ if id, ok := b.selection(t); ok && id == 0 {
93+ t.Fatal("the Wayland selection was cleared by X")
94+ }
95+
96+ // Protection: the X offer is not readable, and X cannot read Wayland.
97+ onDisplay(t, s, func() { s.setXSelection(selClipboard, []string{"UTF8_STRING"}) })
98+ protect(t, s, state, 1, true)
99+ var fd int
100+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
101+ if fd >= 0 {
102+ unix.Close(fd)
103+ t.Fatal("protected selection readable by X")
104+ }
105+ protect(t, s, state, 2, false)
106+}
107+
108+// A Wayland client's text selection is read for X through a pipe it fills.
109+func TestXSelectionReadFromWayland(t *testing.T) {
110+ s, events, commands, dir := keyboardServer(t)
111+ xm := newMockxwmSelection(t)
112+ onDisplay(t, s, func() { s.xwmSel = xm })
113+ a := newClipClient(t, s, dir, events)
114+ a.focus(t, commands)
115+ xm.EXPECT().WaylandSelection(selClipboard, []string(nil)).Maybe() // teardown destroys the source
116+ xm.EXPECT().WaylandSelection(selClipboard, []string{"text/plain"}).Once()
117+ src := a.copy(t)
118+ var fd int
119+ onDisplay(t, s, func() { fd = s.xSelectionRead(selClipboard, "text/plain") })
120+ if fd < 0 {
121+ t.Fatal("no read end")
122+ }
123+ defer unix.Close(fd)
124+ deadline := time.After(2 * time.Second)
125+ for len(src.sends) == 0 {
126+ if err := a.c.Roundtrip(); err != nil {
127+ t.Fatal(err)
128+ }
129+ select {
130+ case <-deadline:
131+ t.Fatal("source never asked to send")
132+ case <-time.After(5 * time.Millisecond):
133+ }
134+ }
135+ buf := make([]byte, 16)
136+ n, err := unix.Read(fd, buf)
137+ if err != nil || string(buf[:n]) != "pasted" {
138+ t.Fatalf("read %q %v", buf[:n], err)
139+ }
140+ onDisplay(t, s, func() {
141+ fd2 := s.xSelectionRead(selClipboard, "image/png")
142+ if fd2 >= 0 {
143+ unix.Close(fd2)
144+ t.Error("unoffered mime readable")
145+ }
146+ })
147+}
M internal/adapters/wayland/constraints.go +14 -13
@@ -61,11 +61,11 @@ func (h pointerWarp) WarpPointer(r *pointerwarp.WpPointerWarpV1, surf *wayland.S
6161 return
6262 }
6363 w := s.windows[s.seat.pointerFocus]
64- if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface {
64+ if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() {
6565 return
6666 }
6767 // Surface-local to window-local (core's, from the geometry origin).
68- g := w.xdg.geometry
68+ g := w.geometry()
6969 s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)})
7070 }
7171
@@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool {
8080 if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 {
8181 return false
8282 }
83- if !w.mapped || !w.xdg.resource.Resource.Alive() {
83+ if !w.mapped || !w.alive() {
8484 return false
8585 }
8686 sent := false
8787 usec := uint64(c.Time / time.Microsecond)
88- for _, rel := range s.relatives[w.xdg.resource.Client()] {
88+ for _, rel := range s.relatives[w.client()] {
8989 if rel.Resource.Alive() {
9090 rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY))
9191 sent = true
@@ -272,7 +272,7 @@ func (s *Server) updateConstraint() {
272272 }
273273 var want *constraint
274274 if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused {
275- if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) {
275+ if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) {
276276 want = c
277277 }
278278 }
@@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) {
298298 if s.protected() {
299299 return
300300 }
301- x := c.surface.xdg
302- if x == nil || x.window == nil {
301+ w := c.surface.toplevelWindow()
302+ if w == nil {
303303 return
304304 }
305305 mode := ports.ConstraintConfine
@@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) {
308308 }
309309 r := c.region
310310 if r.W > 0 && r.H > 0 {
311- r.X -= x.geometry.X
312- r.Y -= x.geometry.Y
311+ g := w.geometry()
312+ r.X -= g.X
313+ r.Y -= g.Y
313314 }
314315 // Core supports one confinement rect, so this bounds the intersection;
315316 // contains uses the exact input region to decide activation.
@@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) {
333334 r = box
334335 }
335336 }
336- s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337+ s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}})
337338 }
338339
339340 // contains reports whether the pointer is in the region: a constraint
340341 // activates only there, so it never warps the pointer.
341342 func (c *constraint) contains(w *window) bool {
342343 r := c.region
343- x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY)
344+ x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY)
344345 all, rects := c.surface.effectiveInput()
345346 if !all {
346- localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y)
347+ localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y)
347348 inside := false
348349 for _, input := range rects {
349350 if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) {
@@ -363,5 +364,5 @@ func (c *constraint) contains(w *window) bool {
363364
364365 // locked reports whether the window's pointer is locked.
365366 func (s *Server) locked(w *window) bool {
366- return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface
367+ return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf()
367368 }
M internal/adapters/wayland/decoration.go +1 -1
@@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1)
8080 func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) {
8181 r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide))
8282 for _, w := range d.s.windows {
83- if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() {
83+ if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() {
8484 w.sendConfigure()
8585 return
8686 }
M internal/adapters/wayland/dialog.go +1 -1
@@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) {
7777 }
7878 w.modal = modal
7979 if w.mapped {
80- w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal})
80+ w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal})
8181 }
8282 }
M internal/adapters/wayland/dmabuf.go +3 -2
@@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) {
223223 return "", false
224224 }
225225 root := toplevelRoot(surf)
226- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) {
226+ w := root.toplevelWindow()
227+ if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) {
227228 return "", false
228229 }
229- return root.xdg.window.last.Output, true
230+ return w.last.Output, true
230231 }
231232
232233 // scanoutFor is the scanout offer for a surface: its window is fullscreen
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
@@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {}
7272 func (s *Server) mappedToplevels() []ports.WindowID {
7373 ids := make([]ports.WindowID, 0, len(s.windows))
7474 for id, w := range s.windows {
75- if w.toplevel != nil && w.mapped {
75+ if w.isToplevel() && w.mapped {
7676 ids = append(ids, id)
7777 }
7878 }
M internal/adapters/wayland/foreigntoplevel.go +6 -6
@@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error {
4444 r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) }
4545 ids := make([]ports.WindowID, 0, len(s.windows))
4646 for id, w := range s.windows {
47- if w.toplevel != nil && w.mapped {
47+ if w.isToplevel() && w.mapped {
4848 ids = append(ids, id)
4949 }
5050 }
@@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte {
186186
187187 // toplevelChanged updates every manager after w mapped or changed.
188188 func (s *Server) toplevelChanged(w *window) {
189- if w.toplevel == nil || !w.mapped {
189+ if !w.isToplevel() || !w.mapped {
190190 return
191191 }
192192 for _, m := range s.toplevelManagers {
@@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() {
220220 for _, l := range s.extToplevelLists {
221221 for id, h := range l.handles {
222222 w := s.windows[id]
223- if h.closed || w == nil || w.toplevel == nil || !w.mapped {
223+ if h.closed || w == nil || !w.isToplevel() || !w.mapped {
224224 l.closed(id)
225225 }
226226 }
@@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() {
231231 for _, m := range s.toplevelManagers {
232232 for id, h := range m.handles {
233233 w := s.windows[id]
234- if h.req.closed || w == nil || w.toplevel == nil || !w.mapped {
234+ if h.req.closed || w == nil || !w.isToplevel() || !w.mapped {
235235 m.closed(id)
236236 }
237237 }
@@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window {
253253 if t.s.protected() || t.closed {
254254 return nil
255255 }
256- if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() {
256+ if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped {
257257 return w
258258 }
259259 return nil
@@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S
274274 }
275275 func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) {
276276 if w := t.window(); w != nil {
277- w.toplevel.SendClose()
277+ w.sendClose()
278278 }
279279 }
280280 func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) {
M internal/adapters/wayland/frames.go +10 -6
@@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent
183183 func toplevelRoot(surf *surface) *surface {
184184 surf = surf.root()
185185 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
186- surf = surf.xdg.window.popup.parent.xdg.surface.root()
186+ surf = surf.xdg.window.popup.parent.surf().root()
187187 }
188188 return surf
189189 }
@@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface {
193193 // for a capture session is invisible.
194194 func (s *Server) invisible(surf *surface) bool {
195195 root := toplevelRoot(surf)
196- return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible
196+ w := root.toplevelWindow()
197+ return w != nil && w.hasLast && !w.last.Visible
197198 }
198199
199200 // suspended reports that nothing draws the toplevel tree, not even a capture
@@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool {
201202 // frame callbacks of its output so the capture keeps moving.
202203 func (s *Server) suspended(surf *surface) bool {
203204 root := toplevelRoot(surf)
204- if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast {
205+ w := root.toplevelWindow()
206+ if w == nil || !w.hasLast {
205207 return false
206208 }
207- return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured
209+ return !w.last.Visible && !w.last.Captured
208210 }
209211
210212 // frameOutput is the pacing class for a surface; "" is the 60 Hz
@@ -212,11 +214,13 @@ func (s *Server) suspended(surf *surface) bool {
212214 func (s *Server) frameOutput(surf *surface) string {
213215 surf = toplevelRoot(surf)
214216 switch {
215- case surf.xdg != nil && surf.xdg.window != nil:
217+ case surf.x11win != nil && surf.x11win.isOverride():
218+ return s.overrideOutput(surf.x11win)
219+ case surf.toplevelWindow() != nil:
216220 if s.suspended(surf) {
217221 return suspendedFrameQueue
218222 }
219- return surf.xdg.window.last.Output
223+ return surf.toplevelWindow().last.Output
220224 case surf.layer != nil && surf.layer.output != nil:
221225 return surf.layer.output.name()
222226 case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed:
M internal/adapters/wayland/globals.go +11 -1
@@ -13,6 +13,8 @@ import (
1313 func registerGlobals(d *server.Display, o Options, s *Server) error {
1414 for _, register := range []func() error{
1515 func() error { return registerXDG(d, s) },
16+ func() error { return registerXwaylandShell(d, s) },
17+ func() error { return registerXwaylandGrab(d, s) },
1618 func() error { return registerLayer(d, s) },
1719 func() error {
1820 // Advertise only with the production owner gate and authoritative
@@ -131,8 +133,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) {
131133 state.identity = c.server.nextSurface
132134 state.wl = w
133135 c.server.surfaces[w.Resource] = state
136+ c.server.surfaceByIdentity[state.identity] = state
134137 state.sendScale()
135- w.OnDestroy = func() { delete(c.server.surfaces, w.Resource); state.Destroy(w) }
138+ w.OnDestroy = func() {
139+ delete(c.server.surfaces, w.Resource)
140+ delete(c.server.surfaceByIdentity, state.identity)
141+ state.Destroy(w)
142+ if state.kind == roleX11 && c.server.xwm != nil {
143+ c.server.xwm.SurfaceGone(state.identity)
144+ }
145+ }
136146 }
137147 }
138148 func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) {
M internal/adapters/wayland/imagecopy.go +1 -1
@@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok
351351 case srcWindow:
352352 // Core's state gives the size; before it, the last configure.
353353 w := c.s.windows[src.window]
354- if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen {
354+ if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen {
355355 return nil, image.Rectangle{}, false, false
356356 }
357357 name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height}
M internal/adapters/wayland/inhibit.go +5 -0
@@ -121,6 +121,11 @@ func (s *Server) syncInhibitors() {
121121 idle[in.window] = true
122122 }
123123 }
124+ // syncKeyboardGrab compares against xgrabbed: it is only written here.
125+ s.xgrabbed = s.xgrabWindow()
126+ if s.xgrabbed != 0 {
127+ shortcuts[s.xgrabbed] = true
128+ }
124129 for id, on := range diffSets(s.shortcutWindows, shortcuts) {
125130 s.emit(ports.ShortcutsInhibit{Window: id, Active: on})
126131 }
M internal/adapters/wayland/outputs.go +72 -1
@@ -42,6 +42,8 @@ type output struct {
4242 global *server.Global
4343 resources []*wayland.Output
4444 xdgOutputs []*xdgoutput.ZxdgOutputV1
45+ // xdgOutputsX are the private xdg-outputs of the Xwayland client (X space).
46+ xdgOutputsX []*xdgoutput.ZxdgOutputV1
4547 }
4648
4749 func (o *output) name() string { return o.place.Info.Name }
@@ -89,6 +91,36 @@ func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) {
8991 }
9092 }
9193
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+
92124 // outputOf returns the output a wl_output resource belongs to, or nil for a
93125 // removed one.
94126 func (s *Server) outputOf(r *wayland.Output) *output {
@@ -162,6 +194,10 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
162194 changed := !slices.Equal(s.outputPlaces, c.Outputs)
163195 focusChanged := s.focusedOutput != c.Focused
164196 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+ }
165201 defer func() {
166202 if changed {
167203 s.refreshOutputManagers()
@@ -244,6 +280,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) {
244280 for _, surf := range s.surfaces {
245281 surf.sendScale()
246282 }
283+ if changed {
284+ s.republishXDGX()
285+ }
247286 s.notifyColorFeedback()
248287 // Layer surfaces sized from the output follow its new logical size.
249288 adopted := false
@@ -279,10 +318,42 @@ type outputHandler struct{}
279318
280319 func (outputHandler) Release(*wayland.Output) {}
281320
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).
282323 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) {
284326 _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s})
285327 })
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+ }
286357 }
287358
288359 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
3131 var parent *window
3232 if parentXDG != nil {
3333 for _, w := range s.windows {
34- if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
34+ if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource {
3535 parent = w
3636 }
3737 }
@@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) {
166166 func (s *Server) topGrab(client server.Client, parent *window) bool {
167167 var top *window
168168 for _, w := range s.windows {
169- if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) {
169+ if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) {
170170 top = w
171171 }
172172 }
M internal/adapters/wayland/scale.go +5 -3
@@ -21,12 +21,14 @@ func (s *Server) outputOfSurface(surf *surface) *output {
2121 surf = surf.root()
2222 // Popups are on their parent's output.
2323 for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil {
24- surf = surf.xdg.window.popup.parent.xdg.surface.root()
24+ surf = surf.xdg.window.popup.parent.surf().root()
2525 }
2626 name := ""
2727 switch {
28- case surf.xdg != nil && surf.xdg.window != nil:
29- name = surf.xdg.window.last.Output
28+ case surf.x11win != nil && surf.x11win.isOverride():
29+ name = s.overrideOutput(surf.x11win)
30+ case surf.toplevelWindow() != nil:
31+ name = surf.toplevelWindow().last.Output
3032 case surf.layer != nil && surf.layer.output != nil:
3133 return surf.layer.output
3234 case surf.lock != nil:
M internal/adapters/wayland/screencast.go +1 -1
@@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) {
146146 gone := ports.CaptureReasonOutputGone
147147 if cmd.Window != 0 {
148148 gone = ports.CaptureReasonWindowGone
149- if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil {
149+ if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() {
150150 src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen}
151151 open.Window = cmd.Window
152152 }
M internal/adapters/wayland/seat.go +11 -9
@@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) {
276276 }
277277
278278 func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard {
279- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
279+ if w == nil || !w.mapped || !w.alive() {
280280 return nil
281281 }
282- return s.clientKeyboards(w.xdg.resource.Client())
282+ return s.clientKeyboards(w.client())
283283 }
284284
285285 // clientKeyboards returns c's wl_keyboards without allocating. The list holds
@@ -300,8 +300,8 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke
300300 // already dead. Admission is enforced before entering or delivering.
301301 return l.surface.wl, s.clientKeyboards(l.surface.wl.Client())
302302 }
303- if w := s.windows[id]; w != nil && w.mapped {
304- if surf := w.xdg.surfaceResource(); surf != nil {
303+ if w := s.windows[id]; w != nil && w.mapped && !w.isOverride() {
304+ if surf := w.surfaceResource(); surf != nil {
305305 return surf, s.windowKeyboards(w)
306306 }
307307 }
@@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi
319319 if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) {
320320 return l.resource.Client(), s.clientPointers(l.resource.Client()), true
321321 }
322- if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() {
323- return w.xdg.resource.Client(), s.windowPointers(w), true
322+ if w := s.windows[id]; w != nil && w.mapped && w.alive() {
323+ return w.client(), s.windowPointers(w), true
324324 }
325325 if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() {
326326 return l.resource.Client(), s.layerPointers(l), true
@@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa
335335 return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y
336336 }
337337 if w := s.windows[id]; w != nil {
338- if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() {
338+ if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() {
339339 x, y := w.surfacePoint(x, y)
340340 return surf, s.windowPointers(w), x, y
341341 }
@@ -455,6 +455,8 @@ func (s *Server) changeFocus(id ports.WindowID) {
455455 }
456456 }
457457 s.textInputFocus()
458+ s.x11Focus()
459+ s.syncKeyboardGrab()
458460 s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus")
459461 s.updateConstraint()
460462 }
@@ -543,10 +545,10 @@ func pointerFrame(p *wayland.Pointer) {
543545 }
544546 }
545547 func (s *Server) windowPointers(w *window) []*wayland.Pointer {
546- if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() {
548+ if w == nil || !w.mapped || !w.alive() {
547549 return nil
548550 }
549- return s.clientPointers(w.xdg.resource.Client())
551+ return s.clientPointers(w.client())
550552 }
551553
552554 func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer {
M internal/adapters/wayland/server.go +51 -16
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/neferwl/internal/adapters/captureallow"
1717 "github.com/bnema/neferwl/internal/adapters/clock"
1818 "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock"
19+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
1920 "github.com/bnema/neferwl/internal/adapters/workspaceid"
2021 "github.com/bnema/neferwl/internal/ports"
2122 "github.com/bnema/purego-libwayland/server"
@@ -51,6 +52,9 @@ type Options struct {
5152 // Casts receives the screencast events and inventory (screencast.go);
5253 // nil serves no casts (Channels.CastCommands is not read).
5354 Casts ports.CastSink
55+ // Xwayland is what the built-in XWM publishes to X11 clients
56+ // (XSETTINGS, root cursor); the zero value publishes nothing.
57+ Xwayland ports.XwaylandSettings
5458 // LogDamage logs the damage of every fresh client commit at debug level
5559 // (--debug=wayland-damage).
5660 LogDamage bool
@@ -160,13 +164,21 @@ type Server struct {
160164 securityStop int
161165 securityWG sync.WaitGroup
162166 securityLive atomic.Int32
163- display *server.Display
164- env procEnv
165- name string
166- cleanup func()
167- log zerowrap.Logger
168- logDamage bool
169- channels Channels
167+ xwayland xwaylandClient // display owner
168+ // xdgPublic and xdgPrivate are the two zxdg_output_manager_v1 globals:
169+ // layout space for clients, X space for Xwayland.
170+ xdgPublic, xdgPrivate *server.Global
171+ // xwm manages the X11 windows of the Xwayland client; x11Slots maps its
172+ // slots to their windows. Display goroutine only.
173+ xwm *xwm.XWM
174+ x11Slots map[xwm.Slot]*window
175+ display *server.Display
176+ env procEnv
177+ name string
178+ cleanup func()
179+ log zerowrap.Logger
180+ logDamage bool
181+ channels Channels
170182 // awaiting holds frame callbacks by output name, due at its next
171183 // frame (frameDue, or its page flip).
172184 awaiting map[string][]*wayland.Callback
@@ -214,8 +226,11 @@ type Server struct {
214226 // foreignExports are the live xdg-foreign handles (foreign.go).
215227 foreignExports map[string]*foreignExport
216228 surfaces map[*server.Resource]*surface
217- buffers map[*server.Resource]clientBuffer
218- dmabuf *dmabufGlobal
229+ // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1
230+ // pairing); kept with surfaces.
231+ surfaceByIdentity map[uint64]*surface
232+ buffers map[*server.Resource]clientBuffer
233+ dmabuf *dmabufGlobal
219234 // hdrOutputs records confirmed DRM modesets; absent outputs are SDR.
220235 hdrOutputs map[string]ports.OutputHDR
221236 colorID uint64
@@ -262,6 +277,8 @@ type Server struct {
262277 // inhibitors are the live inhibitor objects (inhibit.go);
263278 // shortcutWindows and idleWindows what core was told.
264279 inhibitors []*inhibitor
280+ xgrab *xwaylandGrab // Xwayland keyboard grab (xwaylandgrab.go)
281+ xgrabbed ports.WindowID // window the grab inhibits for
265282 shortcutWindows, idleWindows map[ports.WindowID]bool
266283 // busInhibited is the last IdleInhibited value (idle.go).
267284 busInhibited bool
@@ -281,6 +298,8 @@ type Server struct {
281298 workspaceIDs *workspaceid.IDs
282299 outputHeads ports.OutputHeads
283300 outputPlaces ports.Layout
301+ xsettings ports.XwaylandSettings
302+ xspace xwm.Space // the one layout <-> X mapping, shared with the XWM
284303 managementSerial uint32
285304 nextOutputApply uint64
286305 outputReplies map[uint64]*outputConfiguration
@@ -298,6 +317,8 @@ type Server struct {
298317 cursorReady chan struct{}
299318 // Clipboard and primary selection (clipboard.go).
300319 selections [2]*clipSource
320+ xsel [2]*clipSource // the current X-owned source per kind, if any
321+ xwmSel xwmSelection // the XWM's clipboard side; nil without one
301322 // Drag and drop (dnd.go): the drag in progress, drops waiting for
302323 // their finish, and each client's data devices.
303324 dnd *dndState
@@ -364,10 +385,13 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
364385 cleanup: cleanup,
365386 log: log,
366387 logDamage: opts.LogDamage,
388+ xsettings: opts.Xwayland,
367389 channels: ch,
368390 surfaces: make(map[*server.Resource]*surface),
391+ surfaceByIdentity: make(map[uint64]*surface),
369392 buffers: make(map[*server.Resource]clientBuffer),
370393 windows: make(map[ports.WindowID]*window),
394+ x11Slots: make(map[xwm.Slot]*window),
371395 layers: make(map[ports.WindowID]*layerSurface),
372396 nextWindow: 1,
373397 eventReady: make(chan struct{}, 1),
@@ -498,6 +522,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
498522 if err = s.openPaceTimer(); err != nil {
499523 return abort(err)
500524 }
525+ d.SetGlobalFilter(s.globalVisible)
501526 if err = registerGlobals(d, opts, s); err != nil {
502527 return abort(err)
503528 }
@@ -585,6 +610,13 @@ func (s *Server) Run(ctx context.Context) error {
585610 }
586611 s.castInventoryChanged()
587612 err := s.display.Run(ctx)
613+ // The display is gone: only the X connection is left to close.
614+ if s.xwm != nil {
615+ s.xwm.CloseConn()
616+ s.xwm = nil
617+ s.xwmSel = nil
618+ }
619+ s.forgetXwayland()
588620 s.stopSecurityContexts()
589621 wg.Wait()
590622 return err
@@ -774,9 +806,11 @@ func (s *Server) apply(cmd ports.ClientCommand) {
774806 if w := s.windows[c.ID]; w != nil && w.popup != nil {
775807 w.popup.dismiss()
776808 }
809+ case ports.PlaceWindow:
810+ s.x11Place(c)
777811 case ports.ConfigureWindow:
778812 w := s.windows[c.ID]
779- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() || !w.xdg.resource.Resource.Alive() {
813+ if w == nil || !w.toplevelAlive() || !w.alive() {
780814 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window")
781815 return
782816 }
@@ -787,16 +821,17 @@ func (s *Server) apply(cmd ports.ClientCommand) {
787821 scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible
788822 w.last, w.hasLast = c, true
789823 if visibilityChanged {
790- s.relocateCallbacks(w.xdg.surface)
824+ s.relocateCallbacks(w.surf())
791825 s.dropFeedbacks(time.Now())
792826 s.wakePacer() // reconsider FIFO barriers at the new period
793827 }
794828 w.sendConfigure()
829+ s.syncKeyboardGrab()
795830 s.toplevelChanged(w)
796- if scanoutChanged && w.xdg.surface != nil {
797- s.dmabuf.resendSurface(w.xdg.surface)
831+ if scanoutChanged && w.surf() != nil {
832+ s.dmabuf.resendSurface(w.surf())
798833 }
799- if surf := w.xdg.surface; surf != nil && c.Output != "" {
834+ if surf := w.surf(); surf != nil && c.Output != "" {
800835 surf.sendTreeScale()
801836 }
802837 if scanoutChanged {
@@ -817,10 +852,10 @@ func (s *Server) apply(cmd ports.ClientCommand) {
817852 s.setShortcutsInhibit(c)
818853 case ports.CloseWindow:
819854 w := s.windows[c.ID]
820- if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() {
855+ if w == nil || !w.toplevelAlive() {
821856 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window")
822857 return
823858 }
824- w.toplevel.SendClose()
859+ w.sendClose()
825860 }
826861 }
M internal/adapters/wayland/surface.go +15 -2
@@ -28,6 +28,9 @@ const (
2828 roleSessionLock
2929 // roleDragIcon is the icon of a data drag (dnd.go).
3030 roleDragIcon
31+ // roleX11 is the surface of an X11 window paired through
32+ // xwayland_shell_v1.
33+ roleX11
3134 )
3235
3336 type surface struct {
@@ -47,8 +50,12 @@ type surface struct {
4750 formatOpaque bool
4851 kind roleKind
4952 xdg *xdgSurface
50- layer *layerSurface
51- lock *lockSurface
53+ // x11win is the X11 window the surface is associated with; x11Serial is
54+ // the serial of xwayland_surface_v1.set_serial, 0 until set.
55+ x11win *window
56+ x11Serial uint64
57+ layer *layerSurface
58+ lock *lockSurface
5259 // bufferCommitted is permanent, including commits later discarded by FIFO.
5360 bufferCommitted bool
5461 server *Server
@@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID {
192199 if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped {
193200 return s.xdg.window.id
194201 }
202+ if s.x11win != nil && s.x11win.mapped {
203+ return s.x11win.id
204+ }
195205 if s.layer != nil && s.layer.mapped {
196206 return s.layer.id
197207 }
@@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) {
566576 if s.xdg != nil && s.xdg.window != nil {
567577 s.xdg.window.afterCommit()
568578 }
579+ if s.x11win != nil {
580+ s.x11win.afterCommit()
581+ }
569582 if s.lock != nil {
570583 // Role callbacks precede content construction; publish lock placement
571584 // only once validated current dimensions are available.
A internal/adapters/wayland/window.go +112 -0
@@ -0,0 +1,112 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/neferwl/internal/ports"
6+ "github.com/bnema/purego-libwayland/server"
7+)
8+
9+// Role-independent accessors of a window. Code outside xdg.go goes through
10+// them instead of dereferencing w.xdg, which is nil for windows of another
11+// role.
12+
13+// isToplevel reports whether w is a toplevel (xdg or X11), not a popup.
14+func (w *window) isToplevel() bool { return w.toplevel != nil || (w.x11 != nil && !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 +6 -0
@@ -55,6 +55,8 @@ import (
5555 wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign"
5656 wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput"
5757 wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell"
58+ wire_xwaylandkeyboardgrab "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
59+ wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
5860 wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
5961 "github.com/bnema/purego-libwayland/server"
6062 "github.com/bnema/wlturbo"
@@ -253,6 +255,10 @@ var wireSchemas = map[string]wireSchema{
253255 "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil},
254256 "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil},
255257 "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil},
258+ "zwp_xwayland_keyboard_grab_manager_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1Interface, nil, map[uint16]bool{0: true}, nil},
259+ "zwp_xwayland_keyboard_grab_v1": {wire_xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1Interface, nil, map[uint16]bool{0: true}, nil},
260+ "xwayland_shell_v1": {wire_xwaylandshell.XwaylandShellV1Interface, nil, map[uint16]bool{0: true}, nil},
261+ "xwayland_surface_v1": {wire_xwaylandshell.XwaylandSurfaceV1Interface, nil, map[uint16]bool{1: true}, nil},
256262 "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil},
257263 "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil},
258264 }
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 +461 -0
@@ -0,0 +1,461 @@
1+package wayland
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/go-wayland-bindings/server/wayland"
7+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
8+ "github.com/bnema/neferwl/internal/adapters/wayland/xwm"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/bnema/purego-libwayland/server"
11+)
12+
13+// x11Window is the X11 role of a window: the XWM slot, the window's X id and
14+// the wl_surface Xwayland paired with it. Display goroutine only.
15+type x11Window struct {
16+ server *Server
17+ slot xwm.Slot
18+ xid uint32
19+ surface *surface
20+ info xwm.WindowInfo
21+ // ready is set once the XWM read the window's properties (Ready).
22+ ready bool
23+ // override marks an override-redirect window: tracked and drawn, never a
24+ // toplevel nor keyboard focus. rect is its layout rect, noInput keeps the
25+ // pointer off it.
26+ override bool
27+ noInput bool
28+ rect ports.Rect
29+}
30+
31+// xwaylandRestricted are the globals only the Xwayland client may see.
32+func xwaylandRestricted(iface string) bool {
33+ return iface == "xwayland_shell_v1" || iface == "zwp_xwayland_keyboard_grab_manager_v1"
34+}
35+
36+// globalVisible is the display's global filter: the Xwayland-only globals
37+// are visible to, and bindable by, the Xwayland client alone.
38+func (s *Server) globalVisible(c server.Client, g *server.Global) bool {
39+ if g == s.xdgPrivate {
40+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
41+ }
42+ if g == s.xdgPublic {
43+ return c != s.xwayland.client
44+ }
45+ if xwaylandRestricted(g.Interface()) {
46+ return s.xwayland.client != (server.Client{}) && c == s.xwayland.client
47+ }
48+ return true
49+}
50+
51+func registerXwaylandShell(d *server.Display, s *Server) error {
52+ return xwaylandshell.NewXwaylandShellV1Global(d, 1, func(c server.Client, v, id uint32) {
53+ _, _ = xwaylandshell.NewXwaylandShellV1(c, int32(v), id, xwaylandShell{s})
54+ })
55+}
56+
57+type xwaylandShell struct{ s *Server }
58+
59+func (xwaylandShell) Destroy(*xwaylandshell.XwaylandShellV1) {}
60+
61+func (h xwaylandShell) GetXwaylandSurface(r *xwaylandshell.XwaylandShellV1, id uint32, w *wayland.Surface) {
62+ surf := h.s.surfaceOf(w)
63+ if surf == nil {
64+ return
65+ }
66+ if surf.kind != roleNone {
67+ r.PostError(uint32(xwaylandshell.XwaylandShellV1ErrorRole), "surface already has a role")
68+ return
69+ }
70+ if _, err := xwaylandshell.NewXwaylandSurfaceV1(r.Client(), r.Version(), id, xwaylandSurface{h.s, surf}); err != nil {
71+ return
72+ }
73+ surf.kind = roleX11
74+}
75+
76+type xwaylandSurface struct {
77+ s *Server
78+ surf *surface
79+}
80+
81+func (x xwaylandSurface) SetSerial(r *xwaylandshell.XwaylandSurfaceV1, lo, hi uint32) {
82+ serial := uint64(hi)<<32 | uint64(lo)
83+ switch {
84+ case x.surf.x11Serial != 0:
85+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated), "serial already set")
86+ case serial == 0:
87+ r.PostError(uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial), "serial is zero")
88+ default:
89+ x.surf.x11Serial = serial
90+ if x.s.xwm != nil {
91+ x.s.xwm.SetSerial(x.surf.identity, serial)
92+ }
93+ }
94+}
95+
96+func (xwaylandSurface) Destroy(*xwaylandshell.XwaylandSurfaceV1) {}
97+
98+// installXWM makes x the server's window manager. Display goroutine only.
99+func (s *Server) installXWM(x *xwm.XWM) {
100+ s.teardownXWM()
101+ s.xwm = x
102+ s.xwmSel = x
103+ x.UseSpace(&s.xspace)
104+ if err := x.Attach(s.display, hostImpl{s}); err != nil {
105+ s.log.Warn().Err(err).Msg("X11 window management disabled")
106+ s.xwm, s.xwmSel = nil, nil
107+ x.CloseConn()
108+ return
109+ }
110+ // Serials sent before the XWM was attached pair now.
111+ for _, surf := range s.surfaces {
112+ if surf.kind == roleX11 && surf.x11Serial != 0 && surf.x11win == nil {
113+ x.SetSerial(surf.identity, surf.x11Serial)
114+ }
115+ }
116+ for kind, src := range s.selections { // what Wayland offered before the XWM attached
117+ if src != nil && src.alive() {
118+ s.exposeToX(kind, src)
119+ }
120+ }
121+ x.LayoutChanged(true)
122+}
123+
124+// teardownXWM closes the window manager, dropping its windows. Idempotent.
125+func (s *Server) teardownXWM() {
126+ if x := s.xwm; x != nil {
127+ s.xwm, s.xwmSel = nil, nil
128+ x.Close()
129+ }
130+}
131+
132+// x11Configure forwards the latest core configure of an X11 window.
133+func (s *Server) x11Configure(w *window) {
134+ if s.xwm != nil {
135+ s.xwm.Configure(w.x11.slot, w.last)
136+ }
137+}
138+
139+// x11Close asks an X11 window to close.
140+func (s *Server) x11Close(w *window) {
141+ if s.xwm != nil {
142+ s.xwm.CloseWindow(w.x11.slot)
143+ }
144+}
145+
146+// x11Place forwards core's position of an X11 window's client rect.
147+func (s *Server) x11Place(c ports.PlaceWindow) {
148+ if w := s.windows[c.ID]; w != nil && w.x11 != nil && s.xwm != nil {
149+ s.xwm.Place(w.x11.slot, c.X, c.Y)
150+ }
151+}
152+
153+// x11Focus tells the XWM which X window holds the keyboard focus, none when a
154+// native window does.
155+func (s *Server) x11Focus() {
156+ if s.xwm == nil {
157+ return
158+ }
159+ var slot xwm.Slot
160+ if w := s.windows[s.seat.focused]; w != nil && w.x11 != nil {
161+ slot = w.x11.slot
162+ }
163+ s.xwm.Focus(slot)
164+}
165+
166+// hostImpl is the server as the XWM sees it.
167+type hostImpl struct{ s *Server }
168+
169+func (h hostImpl) window(slot xwm.Slot) *window { return h.s.x11Slots[slot] }
170+
171+func (h hostImpl) Created(slot xwm.Slot, xid uint32) ports.WindowID {
172+ s := h.s
173+ w := &window{id: s.nextWindow}
174+ w.x11 = &x11Window{server: s, slot: slot, xid: xid}
175+ s.nextWindow++
176+ s.windows[w.id] = w
177+ s.x11Slots[slot] = w
178+ return w.id
179+}
180+
181+func (h hostImpl) Ready(slot xwm.Slot, info xwm.WindowInfo) {
182+ w := h.window(slot)
183+ if w == nil {
184+ return
185+ }
186+ w.x11.info, w.x11.ready = info, true
187+ w.title, w.appID, w.modal = info.Title, info.AppID, info.Modal
188+ w.parent = h.window(info.Parent)
189+ // A surface associated and committed earlier maps now.
190+ w.x11.tryMap()
191+}
192+
193+func (h hostImpl) Associate(slot xwm.Slot, surfaceID uint64) {
194+ w := h.window(slot)
195+ surf := h.s.surfaceByIdentity[surfaceID]
196+ if w == nil || surf == nil || surf.kind != roleX11 {
197+ return
198+ }
199+ x := w.x11
200+ if x.surface != nil && x.surface != surf {
201+ // A new surface replaces the shown one: core sees a remap.
202+ w.unmap()
203+ }
204+ x.drop()
205+ x.surface, surf.x11win = surf, w
206+ surf.role = func(buffer bool) {
207+ switch {
208+ case surf.destroyed:
209+ w.unmap()
210+ if x.surface == surf {
211+ x.surface = nil
212+ }
213+ surf.x11win = nil
214+ case buffer:
215+ x.tryMap()
216+ case w.mapped:
217+ w.unmap()
218+ }
219+ }
220+ // The buffer may have been committed before the pairing.
221+ x.tryMap()
222+}
223+
224+func (h hostImpl) Dissociate(slot xwm.Slot, destroyed bool) {
225+ w := h.window(slot)
226+ if w == nil {
227+ return
228+ }
229+ w.unmap()
230+ w.x11.drop()
231+ w.x11.ready = false
232+ if destroyed {
233+ for _, c := range h.s.windows {
234+ if c.parent == w {
235+ c.parent = nil
236+ }
237+ }
238+ delete(h.s.windows, w.id)
239+ delete(h.s.x11Slots, slot)
240+ h.s.foreignToplevelGone(w)
241+ }
242+}
243+
244+func (h hostImpl) Title(slot xwm.Slot, title string) {
245+ if w := h.window(slot); w != nil && w.title != title {
246+ w.title = title
247+ h.s.toplevelChanged(w)
248+ if w.mapped {
249+ h.s.castInventoryChanged()
250+ }
251+ }
252+}
253+
254+func (h hostImpl) AppID(slot xwm.Slot, appID string) {
255+ w := h.window(slot)
256+ if w == nil || w.appID == appID {
257+ return
258+ }
259+ w.appID = appID
260+ if w.mapped {
261+ h.s.emit(ports.WindowAppID{ID: w.id, AppID: appID})
262+ h.s.toplevelChanged(w)
263+ h.s.castInventoryChanged()
264+ }
265+}
266+
267+func (h hostImpl) Parent(slot xwm.Slot, parent xwm.Slot) {
268+ if w := h.window(slot); w != nil {
269+ w.setParent(h.window(parent))
270+ }
271+}
272+
273+func (h hostImpl) Fullscreen(slot xwm.Slot, on bool) {
274+ w := h.window(slot)
275+ if w == nil {
276+ return
277+ }
278+ w.x11.info.Fullscreen = on
279+ if w.mapped {
280+ h.s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: on})
281+ }
282+}
283+
284+// drop unlinks the window from its surface.
285+func (x *x11Window) drop() {
286+ if surf := x.surface; surf != nil {
287+ surf.x11win, surf.role = nil, nil
288+ x.surface = nil
289+ }
290+}
291+
292+// tryMap maps the window once the XWM knows it and its surface shows a buffer.
293+func (x *x11Window) tryMap() {
294+ surf := x.surface
295+ if surf == nil {
296+ return
297+ }
298+ w := surf.x11win
299+ if w == nil || w.mapped || !x.ready || surf.destroyed || surf.current == nil {
300+ return
301+ }
302+ s := x.server
303+ w.mapped = true
304+ w.gen++
305+ w.floating = x.info.Floating
306+ w.floatW, w.floatH = 0, 0
307+ if w.floating {
308+ w.floatW, w.floatH = x.info.Width, x.info.Height
309+ }
310+ var parent ports.WindowID
311+ if w.parent != nil && w.parent.mapped {
312+ parent = w.parent.id
313+ }
314+ 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, HasAt: x.info.HasAt, AtX: x.info.AtX, AtY: x.info.AtY})
315+ if x.info.Fullscreen {
316+ s.emit(ports.WindowFullscreenRequest{ID: w.id, Fullscreen: true})
317+ }
318+ s.syncInhibitors()
319+ s.toplevelChanged(w)
320+ s.castInventoryChanged()
321+ 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")
322+ // Called from a commit, the content follows right after; called from the
323+ // pairing, the surface already shows a buffer and is published now.
324+ if surf.has {
325+ surf.resetInputEmission()
326+ surf.redraw()
327+ surf.emitInput()
328+ }
329+}
330+
331+// OverrideMapped starts tracking an override-redirect window. It is announced
332+// to core on the paired surface's first buffer commit, so it is never drawn
333+// without content.
334+func (h hostImpl) OverrideMapped(slot xwm.Slot, xid uint32, surfaceID uint64, x, y, w, h2 int, noInput bool) {
335+ s := h.s
336+ surf := s.surfaceByIdentity[surfaceID]
337+ if surf == nil || surf.kind != roleX11 || surf.x11win != nil {
338+ return
339+ }
340+ if old := s.x11Slots[slot]; old != nil {
341+ h.OverrideGone(slot)
342+ }
343+ win := &window{id: s.nextWindow}
344+ win.x11 = &x11Window{server: s, slot: slot, xid: xid, override: true, noInput: noInput, rect: ports.Rect{X: x, Y: y, W: w, H: h2}}
345+ s.nextWindow++
346+ s.windows[win.id] = win
347+ s.x11Slots[slot] = win
348+ ox := win.x11
349+ ox.surface, surf.x11win = surf, win
350+ surf.role = func(buffer bool) {
351+ switch {
352+ case surf.destroyed:
353+ ox.hide()
354+ ox.surface = nil
355+ surf.x11win = nil
356+ case buffer:
357+ ox.show()
358+ case win.mapped:
359+ ox.hide()
360+ }
361+ }
362+ ox.show()
363+}
364+
365+// OverrideMoved records the new layout rect of an override window and, once
366+// shown, tells core. An unchanged rect costs nothing.
367+func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) {
368+ win := h.s.x11Slots[slot]
369+ if win == nil || win.x11 == nil || !win.x11.override {
370+ return
371+ }
372+ r := ports.Rect{X: x, Y: y, W: w, H: h2}
373+ if r == win.x11.rect {
374+ return
375+ }
376+ win.x11.rect = r
377+ if win.mapped {
378+ h.s.emit(ports.UnmanagedMoved{ID: win.id, X: x, Y: y, Width: w, Height: h2})
379+ win.x11.surface.sendTreeScale()
380+ }
381+}
382+
383+// OverrideGone ends an override window: unmapped, or destroyed.
384+func (h hostImpl) SelectionOwned(kind int, mimes []string) {
385+ h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed")
386+ h.s.setXSelection(kind, mimes)
387+}
388+
389+func (h hostImpl) SelectionRead(kind int, mime string) int {
390+ return h.s.xSelectionRead(kind, mime)
391+}
392+
393+func (h hostImpl) After(d time.Duration, fn func()) func() {
394+ t := h.s.clock.AfterFunc(d, func() { h.s.display.Post(fn) })
395+ return func() { t.Stop() }
396+}
397+
398+func (h hostImpl) OverrideGone(slot xwm.Slot) {
399+ s := h.s
400+ win := s.x11Slots[slot]
401+ if win == nil || win.x11 == nil || !win.x11.override {
402+ return
403+ }
404+ win.x11.hide()
405+ win.x11.drop()
406+ delete(s.windows, win.id)
407+ delete(s.x11Slots, slot)
408+}
409+
410+// show announces the window to core once its surface has content.
411+func (x *x11Window) show() {
412+ surf := x.surface
413+ if surf == nil || surf.x11win == nil || surf.destroyed || surf.current == nil {
414+ return
415+ }
416+ w := surf.x11win
417+ if w.mapped {
418+ return
419+ }
420+ s := x.server
421+ w.mapped = true
422+ r := x.rect
423+ s.emit(ports.UnmanagedMapped{ID: w.id, X: r.X, Y: r.Y, Width: r.W, Height: r.H, NoInput: x.noInput})
424+ surf.sendTreeScale()
425+ if surf.has {
426+ surf.resetInputEmission()
427+ surf.redraw()
428+ surf.emitInput()
429+ }
430+}
431+
432+// hide withdraws a shown override window from core and the seat.
433+func (x *x11Window) hide() {
434+ if x.surface == nil || x.surface.x11win == nil {
435+ return
436+ }
437+ w := x.surface.x11win
438+ s := x.server
439+ if s.seat.pointerFocus == w.id {
440+ s.changePointerFocus(0, 0, 0)
441+ }
442+ if !w.mapped {
443+ return
444+ }
445+ w.mapped = false
446+ x.surface.resetInputEmission()
447+ s.emit(ports.UnmanagedUnmapped{ID: w.id})
448+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
449+}
450+
451+// isOverride reports whether w is an override-redirect X11 window.
452+func (w *window) isOverride() bool { return w.x11 != nil && w.x11.override }
453+
454+// overrideOutput is the name of the output under the origin of an override
455+// window's rect, "" when none.
456+func (s *Server) overrideOutput(w *window) string {
457+ if o, ok := s.outputPlaces.At(float64(w.x11.rect.X), float64(w.x11.rect.Y)); ok {
458+ return o.Info.Name
459+ }
460+ return ""
461+}
A internal/adapters/wayland/x11window_test.go +627 -0
@@ -0,0 +1,627 @@
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/relativepointer"
13+ "github.com/bnema/go-wayland-bindings/server/wayland"
14+ "github.com/bnema/go-wayland-bindings/server/xdgshell"
15+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
16+ "github.com/bnema/go-wayland-bindings/server/xwaylandshell"
17+ "github.com/bnema/neferwl/internal/ports"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+var x11Serial = uint64(0x1234_0000_0042)
23+
24+// x11Rig is a server with an Xwayland client (a real wlturbo client on the
25+// adopted descriptor), a scripted X peer on the WM descriptor and the XWM
26+// between them.
27+type x11Rig struct {
28+ t *testing.T
29+ s *Server
30+ events chan ports.ClientEvent
31+ commands chan ports.ClientCommand
32+ dir string
33+ xc *wlturbo.Display
34+ peer *x11Peer
35+ stop func()
36+ shell uint32
37+ comp uint32
38+ // surfaceProxy, when set, observes the next paired wl_surface.
39+ surfaceProxy func(id uint32) wlturbo.Proxy
40+}
41+
42+func newX11Rig(t *testing.T) *x11Rig {
43+ t.Helper()
44+ s, events, commands, dir := lifecycleServer(t)
45+ r := &x11Rig{t: t, s: s, events: events, commands: commands, dir: dir}
46+ wl := xwaylandPair(t)
47+ s.AttachXwayland(wl[0])
48+ f := os.NewFile(uintptr(wl[1]), "xwayland client")
49+ wl[1] = -1
50+ conn, err := net.FileConn(f)
51+ f.Close()
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if r.xc, err = wlturbo.ConnectFromConn(conn); err != nil {
56+ t.Fatal(err)
57+ }
58+ t.Cleanup(func() { _ = r.xc.Close(); forgetWireClient(r.xc) })
59+ if err := r.xc.Roundtrip(); err != nil {
60+ t.Fatal(err)
61+ }
62+ wm, peer := startX11Peer(t)
63+ r.peer = peer
64+ r.stop = s.AttachXWM(context.Background(), wm)
65+ t.Cleanup(func() { r.stop() })
66+ r.shell = bindProtocol(t, r.xc, "xwayland_shell_v1")
67+ r.comp = bindProtocol(t, r.xc, "wl_compositor")
68+ return r
69+}
70+
71+// x11Window announces and maps X window win with the properties of an
72+// ordinary client, and waits for the XWM to be ready for it.
73+func (r *x11Rig) x11Window(win uint32) {
74+ r.t.Helper()
75+ p := r.peer
76+ str := uint32(31) // XA_STRING
77+ p.setProp(win, 67, str, 8, []byte("inst\x00Class\x00")) // WM_CLASS
78+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("net title"))
79+ p.setProp(win, p.atom("_NET_WM_PID"), 6, 32, x11le32(4242))
80+ p.createNotify(win)
81+ skip := len(p.requests())
82+ p.mapRequest(win)
83+ p.waitReq(skip, func(q x11Req) bool { return q.major == 8 && q.u32(4) == win }) // MapWindow
84+}
85+
86+// pair sends the serial from both sides and commits a buffer on the surface.
87+// It returns the wl_surface id of the Xwayland client.
88+func (r *x11Rig) pair(win uint32, serial uint64) uint32 {
89+ r.t.Helper()
90+ r.peer.clientMessage(win, "WL_SURFACE_SERIAL", uint32(serial), uint32(serial>>32))
91+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
92+ requestProtocol(r.t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
93+ if r.surfaceProxy != nil {
94+ registerWireProxy(r.xc, r.surfaceProxy(surf))
95+ } else {
96+ registerProtocol(r.t, r.xc, surf)
97+ }
98+ requestProtocol(r.t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
99+ registerProtocol(r.t, r.xc, xs)
100+ requestProtocol(r.t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(serial), uint32(serial>>32))
101+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(r.t, r.xc), int32(0), int32(0))
102+ requestProtocol(r.t, r.xc, surf, wayland.SurfaceRequestCommit)
103+ if err := r.xc.Roundtrip(); err != nil {
104+ r.t.Fatal(err)
105+ }
106+ return surf
107+}
108+
109+func awaitEvent[T ports.ClientEvent](t *testing.T, events <-chan ports.ClientEvent) T {
110+ t.Helper()
111+ for deadline := time.After(3 * time.Second); ; {
112+ select {
113+ case ev := <-events:
114+ if v, ok := ev.(T); ok {
115+ return v
116+ }
117+ case <-deadline:
118+ var zero T
119+ t.Fatalf("no %T event", zero)
120+ }
121+ }
122+}
123+
124+func TestXwaylandGlobalFilter(t *testing.T) {
125+ s, _, _, dir := lifecycleServer(t)
126+ other := protocolClient(t, s, dir)
127+ for _, iface := range []string{"xwayland_shell_v1", "zwp_xwayland_keyboard_grab_manager_v1"} {
128+ if _, ok := other.Registry().FindGlobal(iface); ok {
129+ t.Errorf("ordinary client sees %s", iface)
130+ }
131+ }
132+ if _, ok := other.Registry().FindGlobal("xdg_wm_base"); !ok {
133+ t.Error("ordinary globals filtered out")
134+ }
135+ r := newX11Rig(t)
136+ if _, ok := r.xc.Registry().FindGlobal("xwayland_shell_v1"); !ok {
137+ t.Error("Xwayland client misses xwayland_shell_v1")
138+ }
139+ // The ordinary client of the same server still does not see it.
140+ late := protocolClient(t, r.s, r.dir)
141+ if _, ok := late.Registry().FindGlobal("xwayland_shell_v1"); ok {
142+ t.Error("ordinary client sees xwayland_shell_v1 while Xwayland is attached")
143+ }
144+}
145+
146+func TestXwaylandShellRoleErrors(t *testing.T) {
147+ for _, scenario := range []string{"xdg role first", "serial twice", "zero serial"} {
148+ t.Run(scenario, func(t *testing.T) {
149+ r := newX11Rig(t)
150+ c := r.xc
151+ wmBase := bindProtocol(t, c, "xdg_wm_base")
152+ surf := c.AllocateID()
153+ requestProtocol(t, c, r.comp, wayland.CompositorRequestCreateSurface, surf)
154+ registerProtocol(t, c, surf)
155+ xs := c.AllocateID()
156+ switch scenario {
157+ case "xdg role first":
158+ xdg := xs
159+ requestProtocol(t, c, wmBase, xdgshell.WmBaseRequestGetXdgSurface, xdg, surf)
160+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, c.AllocateID(), surf)
161+ expectProtocolError(t, c, r.shell, uint32(xwaylandshell.XwaylandShellV1ErrorRole))
162+ case "serial twice":
163+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
164+ registerProtocol(t, c, xs)
165+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(1), uint32(0))
166+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(2), uint32(0))
167+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorAlreadyAssociated))
168+ case "zero serial":
169+ requestProtocol(t, c, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
170+ registerProtocol(t, c, xs)
171+ requestProtocol(t, c, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(0), uint32(0))
172+ expectProtocolError(t, c, xs, uint32(xwaylandshell.XwaylandSurfaceV1ErrorInvalidSerial))
173+ }
174+ })
175+ }
176+}
177+
178+// A window announced by the X server and a surface carrying the same serial
179+// map as one neferwl window with the X11 info; core commands reach the X side.
180+func TestX11WindowLifecycle(t *testing.T) {
181+ r := newX11Rig(t)
182+ win := uint32(x11Win)
183+ r.x11Window(win)
184+ surf := r.pair(win, x11Serial)
185+
186+ m := awaitEvent[ports.WindowMapped](t, r.events)
187+ if m.AppID != "Class" || m.PID != 4242 || !m.Positioned || m.Floating || m.Slot != "" {
188+ t.Fatalf("mapped %+v", m)
189+ }
190+
191+ // Title and app id changes.
192+ p := r.peer
193+ p.setProp(win, p.atom("_NET_WM_NAME"), p.atom("UTF8_STRING"), 8, []byte("renamed"))
194+ p.propertyNotify(win, p.atom("_NET_WM_NAME"))
195+ p.setProp(win, 67, 31, 8, []byte("inst\x00Other\x00"))
196+ p.propertyNotify(win, 67)
197+ if a := awaitEvent[ports.WindowAppID](t, r.events); a.ID != m.ID || a.AppID != "Other" {
198+ t.Fatalf("app id %+v", a)
199+ }
200+ var title string
201+ waitTrue(t, "title", func() bool {
202+ r.s.display.Do(func() {
203+ if w := r.s.windows[m.ID]; w != nil {
204+ title = w.title
205+ }
206+ })
207+ return title == "renamed"
208+ })
209+
210+ // Fullscreen request from the client.
211+ p.clientMessage(win, "_NET_WM_STATE", 1, p.atom("_NET_WM_STATE_FULLSCREEN"))
212+ if f := awaitEvent[ports.WindowFullscreenRequest](t, r.events); f.ID != m.ID || !f.Fullscreen || f.External {
213+ t.Fatalf("fullscreen %+v", f)
214+ }
215+
216+ // Configure and Place reach the X server as ConfigureWindow.
217+ skip := len(p.requests())
218+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 640, Height: 360, Visible: true}
219+ cfg, skip := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
220+ if w := binary.LittleEndian.Uint32(cfg.raw[20:]); w != 640 {
221+ t.Fatalf("configured width %d, want 640 (%x)", w, cfg.raw)
222+ }
223+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 10, Y: 20}
224+ cfg, _ = p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpConfigureWindow && q.u32(4) == win })
225+ if x, y := int32(binary.LittleEndian.Uint32(cfg.raw[12:])), int32(binary.LittleEndian.Uint32(cfg.raw[16:])); x != 10 || y != 20 {
226+ t.Fatalf("placed at %d,%d, want 10,20", x, y)
227+ }
228+
229+ // Focus.
230+ skip = len(p.requests())
231+ r.commands <- ports.FocusWindow{ID: m.ID}
232+ if f, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) != win {
233+ t.Fatalf("focus on %#x, want %#x", f.u32(4), win)
234+ }
235+
236+ // Close: without WM_DELETE_WINDOW the client is killed.
237+ skip = len(p.requests())
238+ r.commands <- ports.CloseWindow{ID: m.ID}
239+ if k, _ := p.waitReq(skip, func(q x11Req) bool { return q.major == x11OpKillClient }); k.u32(4) != win {
240+ t.Fatalf("killed %#x, want %#x", k.u32(4), win)
241+ }
242+
243+ // Destroying the surface unmaps the window.
244+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestDestroy)
245+ if err := r.xc.Roundtrip(); err != nil {
246+ t.Fatal(err)
247+ }
248+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
249+ t.Fatalf("unmapped %+v", u)
250+ }
251+}
252+
253+// The serial may arrive on the Wayland side before the X side knows it, and
254+// an UnmapNotify unmaps; a remap pairs again.
255+func TestX11WindowSurfaceFirstAndRemap(t *testing.T) {
256+ r := newX11Rig(t)
257+ win := uint32(x11Win)
258+ p := r.peer
259+ surf, xs := r.xc.AllocateID(), r.xc.AllocateID()
260+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateSurface, surf)
261+ registerProtocol(t, r.xc, surf)
262+ requestProtocol(t, r.xc, r.shell, xwaylandshell.XwaylandShellV1RequestGetXwaylandSurface, xs, surf)
263+ registerProtocol(t, r.xc, xs)
264+ requestProtocol(t, r.xc, xs, xwaylandshell.XwaylandSurfaceV1RequestSetSerial, uint32(x11Serial), uint32(x11Serial>>32))
265+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestAttach, shmBuffer(t, r.xc), int32(0), int32(0))
266+ requestProtocol(t, r.xc, surf, wayland.SurfaceRequestCommit)
267+ if err := r.xc.Roundtrip(); err != nil {
268+ t.Fatal(err)
269+ }
270+ r.x11Window(win)
271+ p.clientMessage(win, "WL_SURFACE_SERIAL", uint32(x11Serial), uint32(x11Serial>>32))
272+ m := awaitEvent[ports.WindowMapped](t, r.events)
273+ if m.Remap {
274+ t.Fatalf("first map flagged as remap: %+v", m)
275+ }
276+ p.unmapNotify(win)
277+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
278+ t.Fatalf("unmapped %+v", u)
279+ }
280+ p.destroyNotify(win)
281+ waitTrue(t, "window forgotten", func() bool {
282+ gone := false
283+ r.s.display.Do(func() { gone = r.s.windows[m.ID] == nil })
284+ return gone
285+ })
286+}
287+
288+// stop closes the XWM: its windows unmap, and the server keeps serving.
289+func TestX11StopUnmapsWindows(t *testing.T) {
290+ r := newX11Rig(t)
291+ win := uint32(x11Win)
292+ r.x11Window(win)
293+ r.pair(win, x11Serial)
294+ m := awaitEvent[ports.WindowMapped](t, r.events)
295+ r.stop()
296+ if u := awaitEvent[ports.WindowUnmapped](t, r.events); u.ID != m.ID {
297+ t.Fatalf("unmapped %+v", u)
298+ }
299+ r.s.display.Do(func() {
300+ if r.s.xwm != nil || r.s.windows[m.ID] != nil || len(r.s.x11Slots) != 0 {
301+ t.Error("XWM state left behind")
302+ }
303+ })
304+ r.stop() // idempotent
305+}
306+
307+// Boot gives up as soon as its context ends, even against a silent server,
308+// and leaves the server serving.
309+func TestAttachXWMBootAbortsWithContext(t *testing.T) {
310+ s, _, _, dir := lifecycleServer(t)
311+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
312+ if err != nil {
313+ t.Fatal(err)
314+ }
315+ defer unix.Close(fds[1]) // the silent server end
316+ ctx, cancel := context.WithCancel(context.Background())
317+ done := make(chan func(), 1)
318+ go func() { done <- s.AttachXWM(ctx, fds[0]) }()
319+ select {
320+ case <-done:
321+ t.Fatal("AttachXWM returned before the context ended")
322+ case <-time.After(150 * time.Millisecond):
323+ }
324+ cancel()
325+ select {
326+ case stop := <-done:
327+ stop()
328+ case <-time.After(2 * time.Second):
329+ t.Fatal("AttachXWM did not abort with its context")
330+ }
331+ assertFDClosed(t, fds[0])
332+ protocolClient(t, s, dir) // still serving
333+}
334+
335+// Xwayland locks the pointer for games: the constraint reaches core for the
336+// X11 window like for an xdg toplevel.
337+func TestX11WindowPointerLock(t *testing.T) {
338+ r := newX11Rig(t)
339+ win := uint32(x11Win)
340+ r.x11Window(win)
341+ surf := r.pair(win, x11Serial)
342+ m := awaitEvent[ports.WindowMapped](t, r.events)
343+
344+ seat := bindProtocol(t, r.xc, "wl_seat")
345+ registerProtocol(t, r.xc, seat)
346+ pointer := r.xc.AllocateID()
347+ requestProtocol(t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
348+ registerProtocol(t, r.xc, pointer)
349+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
350+ lock := r.xc.AllocateID()
351+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
352+ registerProtocol(t, r.xc, lock)
353+ if err := r.xc.Roundtrip(); err != nil {
354+ t.Fatal(err)
355+ }
356+ r.commands <- ports.PointerFocus{ID: m.ID}
357+ r.commands <- ports.FocusWindow{ID: m.ID}
358+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock {
359+ t.Fatalf("constrained %+v", got)
360+ }
361+}
362+
363+// grabRig is an X11 window with an Xwayland keyboard grab on its surface.
364+func grabRig(t *testing.T) (r *x11Rig, w ports.WindowID, surf uint32) {
365+ t.Helper()
366+ r = newX11Rig(t)
367+ r.x11Window(x11Win)
368+ surf = r.pair(x11Win, x11Serial)
369+ w = awaitEvent[ports.WindowMapped](t, r.events).ID
370+ seat := bindProtocol(t, r.xc, "wl_seat")
371+ registerProtocol(t, r.xc, seat)
372+ mgr := bindProtocol(t, r.xc, "zwp_xwayland_keyboard_grab_manager_v1")
373+ grab := r.xc.AllocateID()
374+ requestProtocol(t, r.xc, mgr, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1RequestGrabKeyboard, grab, surf, seat)
375+ registerProtocol(t, r.xc, grab)
376+ if err := r.xc.Roundtrip(); err != nil {
377+ t.Fatal(err)
378+ }
379+ return r, w, grab
380+}
381+
382+// noInhibit fails when a ShortcutsInhibit event arrives shortly.
383+func noInhibit(t *testing.T, r *x11Rig) {
384+ t.Helper()
385+ if err := r.xc.Roundtrip(); err != nil {
386+ t.Fatal(err)
387+ }
388+ for deadline := time.After(150 * time.Millisecond); ; {
389+ select {
390+ case ev := <-r.events:
391+ if v, ok := ev.(ports.ShortcutsInhibit); ok {
392+ t.Fatalf("unexpected %+v", v)
393+ }
394+ case <-deadline:
395+ return
396+ }
397+ }
398+}
399+
400+func TestXwaylandKeyboardGrabThreeStates(t *testing.T) {
401+ r, w, grab := grabRig(t)
402+ // Windowed and focused: accepted, no effect.
403+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true}
404+ r.commands <- ports.FocusWindow{ID: w}
405+ noInhibit(t, r)
406+ // Focused and fullscreen: inhibits.
407+ r.commands <- ports.ConfigureWindow{ID: w, Width: 100, Height: 100, Visible: true, Fullscreen: true}
408+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w, Active: true}) {
409+ t.Fatal(v)
410+ }
411+ // Focus lost: released.
412+ r.commands <- ports.FocusWindow{}
413+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
414+ t.Fatal(v)
415+ }
416+ // Back, then the grab object goes away.
417+ r.commands <- ports.FocusWindow{ID: w}
418+ awaitEvent[ports.ShortcutsInhibit](t, r.events)
419+ requestProtocol(t, r.xc, grab, xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1RequestDestroy)
420+ if v := awaitEvent[ports.ShortcutsInhibit](t, r.events); v != (ports.ShortcutsInhibit{Window: w}) {
421+ t.Fatal(v)
422+ }
423+}
424+
425+// x11Pointer gives the Xwayland client a wl_pointer and a relative pointer,
426+// both with event logs.
427+func (r *x11Rig) x11Pointer() (pointer uint32, ptrEvents, relEvents chan []float64) {
428+ r.t.Helper()
429+ seat := bindProtocol(r.t, r.xc, "wl_seat")
430+ registerProtocol(r.t, r.xc, seat)
431+ pointer = r.xc.AllocateID()
432+ ptrEvents = logProxy(r.t, r.xc, pointer)
433+ requestProtocol(r.t, r.xc, seat, wayland.SeatRequestGetPointer, pointer)
434+ mgr := bindProtocol(r.t, r.xc, "zwp_relative_pointer_manager_v1")
435+ rel := r.xc.AllocateID()
436+ relEvents = logProxy(r.t, r.xc, rel)
437+ requestProtocol(r.t, r.xc, mgr, relativepointer.ZwpRelativePointerManagerV1RequestGetRelativePointer, rel, pointer)
438+ return pointer, ptrEvents, relEvents
439+}
440+
441+// A lock on an X11 surface activates with pointer and keyboard focus, sends
442+// relative motion to the Xwayland client and deactivates when a native
443+// window takes the focus; it comes back with the X11 focus.
444+func TestX11LockFollowsFocusAndRelativeMotion(t *testing.T) {
445+ r := newX11Rig(t)
446+ win := uint32(x11Win)
447+ r.x11Window(win)
448+ surf := r.pair(win, x11Serial)
449+ m := awaitEvent[ports.WindowMapped](t, r.events)
450+ pointer, _, relEvents := r.x11Pointer()
451+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
452+ lock := r.xc.AllocateID()
453+ lockEvents := logProxy(t, r.xc, lock)
454+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestLockPointer, lock, surf, pointer, uint32(0), uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
455+ if err := r.xc.Roundtrip(); err != nil {
456+ t.Fatal(err)
457+ }
458+ if len(lockEvents) > 0 {
459+ t.Fatal("locked without focus")
460+ }
461+
462+ r.commands <- ports.PointerFocus{ID: m.ID}
463+ r.commands <- ports.FocusWindow{ID: m.ID}
464+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
465+ t.Fatalf("lock event %v", ev)
466+ }
467+ if got := constrained(t, r.events); got.ID != m.ID || got.Mode != ports.ConstraintLock || got.Rect != (ports.Rect{}) {
468+ t.Fatalf("constrained %+v", got)
469+ }
470+
471+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 5, Y: 5, DX: 2.5, DY: -1, UnaccelDX: 4, UnaccelDY: -2}
472+ if ev := next(t, r.xc, relEvents); len(ev) != 5 || ev[1] != 2.5 || ev[2] != -1 || ev[3] != 4 || ev[4] != -2 {
473+ t.Fatalf("relative motion %v", ev)
474+ }
475+
476+ // A native window takes the focus: the X11 lock ends and the XWM drops
477+ // the X focus.
478+ nc := protocolClient(t, r.s, r.dir)
479+ nm, _, _ := surfaceMapper(t, nc, r.events)()
480+ skip := len(r.peer.requests())
481+ r.commands <- ports.FocusWindow{ID: nm.ID}
482+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventUnlocked) {
483+ t.Fatalf("unlock event %v", ev)
484+ }
485+ if got := constrained(t, r.events); got.ID != 0 {
486+ t.Fatalf("constrained %+v after native focus", got)
487+ }
488+ if f, _ := r.peer.waitReq(skip, func(q x11Req) bool { return q.major == x11OpSetInputFocus }); f.u32(4) == win {
489+ t.Fatalf("X focus still on %#x", win)
490+ }
491+
492+ r.commands <- ports.FocusWindow{ID: m.ID}
493+ if ev := next(t, r.xc, lockEvents); ev[0] != float64(pointerconstraints.ZwpLockedPointerV1EventLocked) {
494+ t.Fatalf("relock event %v", ev)
495+ }
496+}
497+
498+// A confine on an X11 surface reaches core with its region as given: an X11
499+// window has no geometry offset.
500+func TestX11ConfineRegionHasNoOffset(t *testing.T) {
501+ r := newX11Rig(t)
502+ win := uint32(x11Win)
503+ r.x11Window(win)
504+ surf := r.pair(win, x11Serial)
505+ m := awaitEvent[ports.WindowMapped](t, r.events)
506+ pointer, _, _ := r.x11Pointer()
507+ region := r.xc.AllocateID()
508+ registerProtocol(t, r.xc, region)
509+ requestProtocol(t, r.xc, r.comp, wayland.CompositorRequestCreateRegion, region)
510+ requestProtocol(t, r.xc, region, wayland.RegionRequestAdd, int32(5), int32(6), int32(20), int32(30))
511+ constraints := bindProtocol(t, r.xc, "zwp_pointer_constraints_v1")
512+ confine := r.xc.AllocateID()
513+ confineEvents := logProxy(t, r.xc, confine)
514+ requestProtocol(t, r.xc, constraints, pointerconstraints.ZwpPointerConstraintsV1RequestConfinePointer, confine, surf, pointer, region, uint32(pointerconstraints.ZwpPointerConstraintsV1LifetimePersistent))
515+ if err := r.xc.Roundtrip(); err != nil {
516+ t.Fatal(err)
517+ }
518+ r.state(func() {
519+ if g := r.s.windows[m.ID].geometry(); g != (ports.Rect{}) {
520+ t.Errorf("X11 geometry %+v, want zero", g)
521+ }
522+ })
523+
524+ r.commands <- ports.PointerFocus{ID: m.ID, X: 10, Y: 10}
525+ r.commands <- ports.FocusWindow{ID: m.ID}
526+ r.commands <- ports.PointerMotionTo{ID: m.ID, X: 10, Y: 10}
527+ if ev := next(t, r.xc, confineEvents); ev[0] != float64(pointerconstraints.ZwpConfinedPointerV1EventConfined) {
528+ t.Fatalf("confine event %v", ev)
529+ }
530+ want := ports.PointerConstrained{ID: m.ID, PointerConstraint: ports.PointerConstraint{Mode: ports.ConstraintConfine, Rect: ports.Rect{X: 5, Y: 6, W: 20, H: 30}}}
531+ if got := constrained(t, r.events); got != want {
532+ t.Fatalf("constrained %+v, want %+v", got, want)
533+ }
534+}
535+
536+// waitOn waits until window id's surface is on output name: commands reach
537+// the display goroutine asynchronously.
538+func (r *x11Rig) waitOn(id ports.WindowID, name string) {
539+ r.t.Helper()
540+ waitTrue(r.t, "surface on "+name, func() bool {
541+ ok := false
542+ r.state(func() {
543+ if w := r.s.windows[id]; w != nil {
544+ on := w.surf().on
545+ ok = on != nil && on.name() == name
546+ }
547+ })
548+ return ok
549+ })
550+}
551+
552+// An X11 window is on one output only, even straddling two: a single
553+// wl_surface.enter, and a leave only when its output changes.
554+func TestX11WindowEntersOneOutput(t *testing.T) {
555+ r := newX11Rig(t)
556+ r.commands <- ports.SetOutputs{Outputs: ports.Layout{
557+ {Info: ports.OutputInfo{Name: "A"}, X: 0, Y: 0, Width: 1920, Height: 1080, Scale: 1},
558+ {Info: ports.OutputInfo{Name: "B"}, X: 1920, Y: 0, Width: 1920, Height: 1080, Scale: 1},
559+ }, Focused: "A"}
560+ r.state(func() {}) // the layout is applied
561+ if err := r.xc.Roundtrip(); err != nil {
562+ t.Fatal(err)
563+ }
564+ outputs := 0
565+ for _, g := range r.xc.Registry().GetGlobals() {
566+ if g.Interface != "wl_output" {
567+ continue
568+ }
569+ id, err := bindWireID(r.xc, g.Name, g.Interface, 1)
570+ if err != nil {
571+ t.Fatal(err)
572+ }
573+ registerProtocol(t, r.xc, id)
574+ outputs++
575+ }
576+ if outputs != 2 {
577+ t.Fatalf("%d wl_output globals, want 2", outputs)
578+ }
579+
580+ win := uint32(x11Win)
581+ r.x11Window(win)
582+ events := make(chan []float64, 64)
583+ r.surfaceProxy = func(id uint32) wlturbo.Proxy {
584+ p := &eventLog{events: events}
585+ p.SetID(id)
586+ return p
587+ }
588+ r.pair(win, x11Serial)
589+ m := awaitEvent[ports.WindowMapped](t, r.events)
590+
591+ // Straddling both outputs, configured on A.
592+ r.commands <- ports.PlaceWindow{ID: m.ID, X: 1900, Y: 0}
593+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "A"}
594+ r.waitOn(m.ID, "A")
595+ if err := r.xc.Roundtrip(); err != nil {
596+ t.Fatal(err)
597+ }
598+ count := func() (enters, leaves int) {
599+ for len(events) > 0 {
600+ switch ev := <-events; ev[0] {
601+ case float64(wayland.SurfaceEventEnter):
602+ enters++
603+ case float64(wayland.SurfaceEventLeave):
604+ leaves++
605+ }
606+ }
607+ return
608+ }
609+ if e, l := count(); e != 1 || l != 0 {
610+ t.Fatalf("straddling window: %d enters, %d leaves, want 1, 0", e, l)
611+ }
612+ r.state(func() {
613+ if on := r.s.windows[m.ID].surf().on; on == nil || on.name() != "A" {
614+ t.Errorf("surface on %v, want A", on)
615+ }
616+ })
617+
618+ // Moving to B leaves A and enters B once.
619+ r.commands <- ports.ConfigureWindow{ID: m.ID, Width: 200, Height: 100, Visible: true, Output: "B"}
620+ r.waitOn(m.ID, "B")
621+ if err := r.xc.Roundtrip(); err != nil {
622+ t.Fatal(err)
623+ }
624+ if e, l := count(); e != 1 || l != 1 {
625+ t.Fatalf("move to B: %d enters, %d leaves, want 1, 1", e, l)
626+ }
627+}
M internal/adapters/wayland/xdg.go +32 -21
@@ -128,10 +128,12 @@ func (x *xdgSurface) Destroy(r *xdgshell.Surface) {
128128
129129 // window is an xdg_toplevel, or an xdg_popup when popup is set.
130130 type window struct {
131- id ports.WindowID
132- toplevel *xdgshell.Toplevel
133- popup *popup
134- xdg *xdgSurface
131+ id ports.WindowID
132+ toplevel *xdgshell.Toplevel
133+ popup *popup
134+ xdg *xdgSurface
135+ // x11 is set for the window of an X11 client (x11window.go).
136+ x11 *x11Window
135137 appID, title string
136138 mapped bool
137139 // gen counts the mappings: an unmap and a remap is a new toplevel to
@@ -151,8 +153,8 @@ type window struct {
151153 hasLast bool
152154 }
153155
154-// sendConfigure sends w.last as xdg_toplevel + xdg_surface configure.
155-func (w *window) sendConfigure() {
156+// sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure.
157+func (w *window) sendXDGConfigure() {
156158 c := w.last
157159 w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version()))
158160 s := w.xdg.server
@@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte {
192194 }
193195
194196 func (w *window) unmap() {
195- if w.xdg.server.seat.pointerFocus == w.id {
196- w.xdg.server.changePointerFocus(0, 0, 0)
197+ s := w.srv()
198+ if s.seat.pointerFocus == w.id {
199+ s.changePointerFocus(0, 0, 0)
197200 }
198- if w.xdg.server.seat.focused == w.id {
199- w.xdg.server.changeFocus(0)
201+ if s.seat.focused == w.id {
202+ s.changeFocus(0)
200203 }
201204 if !w.mapped {
202205 return
203206 }
204207 w.mapped = false
205- w.xdg.server.toplevelClosed(w.id)
206- w.xdg.surface.resetInputEmission()
207- w.xdg.configured = false
208- w.xdg.acked = false
209- w.xdg.serials = nil
210- w.xdg.server.emit(ports.WindowUnmapped{ID: w.id})
211- w.xdg.server.syncInhibitors()
212- w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
208+ s.toplevelClosed(w.id)
209+ if surf := w.surf(); surf != nil {
210+ surf.resetInputEmission()
211+ }
212+ if x := w.xdg; x != nil {
213+ x.configured = false
214+ x.acked = false
215+ x.serials = nil
216+ }
217+ s.emit(ports.WindowUnmapped{ID: w.id})
218+ s.syncInhibitors()
219+ s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true})
213220 }
214221 func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) {
215222 if x.window != nil {
@@ -337,7 +344,7 @@ func (w *window) setParent(p *window) {
337344 if p != nil && p.mapped {
338345 id = p.id
339346 }
340- w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id})
347+ w.srv().emit(ports.WindowParent{ID: w.id, Parent: id})
341348 }
342349 func (t top) SetTitle(_ *xdgshell.Toplevel, title string) {
343350 if title == t.w.title {
@@ -417,7 +424,7 @@ func (w *window) afterCommit() {
417424 }
418425 if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) {
419426 w.floatW, w.floatH = fw, fh
420- w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
427+ w.srv().emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh})
421428 }
422429 }
423430
@@ -431,6 +438,10 @@ func (w *window) floats() bool {
431438
432439 // size is the window geometry size, else the surface size, in logical pixels.
433440 func (w *window) size() (int, int) {
441+ if w.xdg == nil {
442+ c := w.surf().content
443+ return c.LogicalW, c.LogicalH
444+ }
434445 if g := w.xdg.geometry; g.W > 0 && g.H > 0 {
435446 return g.W, g.H
436447 }
@@ -441,7 +452,7 @@ func (w *window) size() (int, int) {
441452 // surfacePoint turns window coordinates (core's, from the geometry origin)
442453 // into surface coordinates.
443454 func (w *window) surfacePoint(x, y float64) (float64, float64) {
444- g := w.xdg.geometry
455+ g := w.geometry()
445456 return x + float64(g.X), y + float64(g.Y)
446457 }
447458
A internal/adapters/wayland/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.BootSettings(ctx, wmFD, log, s.xsettings)
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/xwaylandgrab.go +90 -0
@@ -0,0 +1,90 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/go-wayland-bindings/server/wayland"
5+ "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab"
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-libwayland/server"
8+)
9+
10+// zwp_xwayland_keyboard_grab_v1. Xwayland asks for it when an X client
11+// takes an active keyboard grab. It is a shortcuts inhibitor, but only
12+// while the grabbing surface's window has the keyboard focus and is
13+// fullscreen: a windowed app must not lock the compositor binds out.
14+// Otherwise the request is accepted and has no effect. Never honoured
15+// while the session is protected. The global is visible to Xwayland only
16+// (xwaylandRestricted).
17+
18+// xwaylandGrab is the seat's single grab.
19+type xwaylandGrab struct {
20+ res *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1
21+ surf *surface
22+}
23+
24+func registerXwaylandGrab(d *server.Display, s *Server) error {
25+ return xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
26+ _, _ = xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabManagerV1(c, int32(v), id, xwaylandGrabManager{s})
27+ })
28+}
29+
30+type xwaylandGrabManager struct{ s *Server }
31+
32+func (xwaylandGrabManager) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1) {}
33+
34+// GrabKeyboard replaces the seat's grab with a new one.
35+func (m xwaylandGrabManager) GrabKeyboard(r *xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabManagerV1, id uint32, surf *wayland.Surface, _ *wayland.Seat) {
36+ state := m.s.surfaceOf(surf)
37+ if state == nil {
38+ return
39+ }
40+ g := &xwaylandGrab{surf: state}
41+ res, err := xwaylandkeyboardgrab.NewZwpXwaylandKeyboardGrabV1(r.Client(), r.Version(), id, xwaylandGrabHandler{})
42+ if err != nil {
43+ return
44+ }
45+ g.res = res
46+ old := m.s.xgrab
47+ m.s.xgrab = g
48+ res.OnDestroy = func() {
49+ if m.s.xgrab == g {
50+ m.s.xgrab = nil
51+ m.s.syncKeyboardGrab()
52+ }
53+ }
54+ if old != nil && old.res != nil && old.res.Resource.Alive() {
55+ old.res.Resource.Destroy()
56+ }
57+ m.s.syncKeyboardGrab()
58+}
59+
60+type xwaylandGrabHandler struct{}
61+
62+func (xwaylandGrabHandler) Destroy(*xwaylandkeyboardgrab.ZwpXwaylandKeyboardGrabV1) {}
63+
64+// xgrabWindow is the window whose grab is in effect (0: none).
65+func (s *Server) xgrabWindow() ports.WindowID {
66+ g := s.xgrab
67+ if g == nil || g.surf == nil || g.surf.destroyed || !g.res.Resource.Alive() {
68+ return 0
69+ }
70+ if s.security != nil && s.security.Snapshot().Protected {
71+ return 0
72+ }
73+ id := g.surf.root().windowID()
74+ if id == 0 || id != s.seat.focused {
75+ return 0
76+ }
77+ if w := s.windows[id]; w == nil || !w.hasLast || !w.last.Fullscreen {
78+ return 0
79+ }
80+ return id
81+}
82+
83+// syncKeyboardGrab re-evaluates the grab after a focus or fullscreen
84+// change, and reports to core only when its effect changed.
85+func (s *Server) syncKeyboardGrab() {
86+ if id := s.xgrabWindow(); id != s.xgrabbed {
87+ s.xgrabbed = id
88+ s.syncInhibitors()
89+ }
90+}
A internal/adapters/wayland/xwm/atoms.go +118 -0
@@ -0,0 +1,118 @@
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+ atomString
50+ atomText
51+ atomTextPlain
52+ atomTextPlainUTF8
53+ atomIncr
54+ atomTimestamp
55+ atomNeferwlData
56+ atomNeferwlTargets
57+ atomManager
58+ atomXSettingsS0
59+ atomXSettingsSettings
60+ atomCount
61+)
62+
63+var atomNames = [atomCount]string{
64+ atomWMProtocols: "WM_PROTOCOLS",
65+ atomWMDeleteWindow: "WM_DELETE_WINDOW",
66+ atomWMTakeFocus: "WM_TAKE_FOCUS",
67+ atomWMState: "WM_STATE",
68+ atomWMChangeState: "WM_CHANGE_STATE",
69+ atomWMS0: "WM_S0",
70+ atomUTF8String: "UTF8_STRING",
71+ atomNetWMCMS0: "_NET_WM_CM_S0",
72+ atomNetSupported: "_NET_SUPPORTED",
73+ atomNetSupportingWMCheck: "_NET_SUPPORTING_WM_CHECK",
74+ atomNetWMName: "_NET_WM_NAME",
75+ atomNetWMPID: "_NET_WM_PID",
76+ atomNetWMWindowType: "_NET_WM_WINDOW_TYPE",
77+ atomNetWMWindowTypeNormal: "_NET_WM_WINDOW_TYPE_NORMAL",
78+ atomNetWMWindowTypeDialog: "_NET_WM_WINDOW_TYPE_DIALOG",
79+ atomNetWMWindowTypeUtility: "_NET_WM_WINDOW_TYPE_UTILITY",
80+ atomNetWMWindowTypeSplash: "_NET_WM_WINDOW_TYPE_SPLASH",
81+ atomNetWMWindowTypeToolbar: "_NET_WM_WINDOW_TYPE_TOOLBAR",
82+ atomNetWMWindowTypeMenu: "_NET_WM_WINDOW_TYPE_MENU",
83+ atomNetWMWindowTypeDropdownMenu: "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU",
84+ atomNetWMWindowTypePopupMenu: "_NET_WM_WINDOW_TYPE_POPUP_MENU",
85+ atomNetWMWindowTypeTooltip: "_NET_WM_WINDOW_TYPE_TOOLTIP",
86+ atomNetWMWindowTypeNotification: "_NET_WM_WINDOW_TYPE_NOTIFICATION",
87+ atomNetWMWindowTypeCombo: "_NET_WM_WINDOW_TYPE_COMBO",
88+ atomNetWMWindowTypeDND: "_NET_WM_WINDOW_TYPE_DND",
89+ atomNetWMState: "_NET_WM_STATE",
90+ atomNetWMStateModal: "_NET_WM_STATE_MODAL",
91+ atomNetWMStateFullscreen: "_NET_WM_STATE_FULLSCREEN",
92+ atomNetWMStateFocused: "_NET_WM_STATE_FOCUSED",
93+ atomNetWMStateMaximizedVert: "_NET_WM_STATE_MAXIMIZED_VERT",
94+ atomNetWMStateMaximizedHorz: "_NET_WM_STATE_MAXIMIZED_HORZ",
95+ atomNetActiveWindow: "_NET_ACTIVE_WINDOW",
96+ atomNetClientList: "_NET_CLIENT_LIST",
97+ atomNetClientListStacking: "_NET_CLIENT_LIST_STACKING",
98+ atomNetWMMoveresize: "_NET_WM_MOVERESIZE",
99+ atomNetWMBypassCompositor: "_NET_WM_BYPASS_COMPOSITOR",
100+ atomWLSurfaceID: "WL_SURFACE_ID",
101+ atomWLSurfaceSerial: "WL_SURFACE_SERIAL",
102+ atomNetWMIcon: "_NET_WM_ICON",
103+ atomMotifWMHints: "_MOTIF_WM_HINTS",
104+ atomClipboard: "CLIPBOARD",
105+ atomPrimary: "PRIMARY",
106+ atomTargets: "TARGETS",
107+ atomString: "STRING",
108+ atomText: "TEXT",
109+ atomTextPlain: "text/plain",
110+ atomTextPlainUTF8: "text/plain;charset=utf-8",
111+ atomIncr: "INCR",
112+ atomTimestamp: "TIMESTAMP",
113+ atomNeferwlData: "_NEFERWL_DATA",
114+ atomNeferwlTargets: "_NEFERWL_TARGETS",
115+ atomManager: "MANAGER",
116+ atomXSettingsS0: "_XSETTINGS_S0",
117+ atomXSettingsSettings: "_XSETTINGS_SETTINGS",
118+}
A internal/adapters/wayland/xwm/configure.go +318 -0
@@ -0,0 +1,318 @@
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+ x.refreshXSettings()
127+ if !changed {
128+ return
129+ }
130+ for i := range x.tab.recs {
131+ r, s := &x.tab.recs[i], Slot(i+1)
132+ switch {
133+ case r.kind == kindManaged && r.placed:
134+ r.pend.x, r.pend.y = x.toX(r)
135+ x.markDirty(s, r)
136+ case r.kind == kindUnmanaged && r.ovPaired:
137+ x.queueOverride(s, r)
138+ }
139+ }
140+ x.wake()
141+}
142+
143+// flushConfigures sends the pending configures, once per display iteration,
144+// then what focus left to do.
145+func (x *XWM) flushConfigures() {
146+ for _, s := range x.dirty {
147+ if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty {
148+ x.sendPending(s, r)
149+ }
150+ }
151+ x.dirty = x.dirty[:0]
152+ x.flushOverrides()
153+ x.flushFocus()
154+}
155+
156+// sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM
157+// 4.1.5 asks for, since Xwayland may not move the window) and the
158+// _NET_WM_STATE bits that changed.
159+func (x *XWM) sendPending(s Slot, r *record) {
160+ r.pend.dirty = false
161+ p := &r.pend
162+ w, h := r.size()
163+ if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) {
164+ var v xproto.ConfigureWindowValues
165+ v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0)
166+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
167+ r.x, r.y, r.w, r.h = p.x, p.y, w, h
168+ x.sendConfigureNotify(r)
169+ r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h
170+ }
171+ var bits uint8
172+ if p.fullscreen {
173+ bits |= stateFullscreen
174+ }
175+ if p.activated {
176+ bits |= stateFocused
177+ }
178+ r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated
179+ r.fullscreen = p.fullscreen // ours now: the property is not read back
180+ if bits != r.stateSent {
181+ r.stateSent = bits
182+ x.writeState(r)
183+ }
184+}
185+
186+// writeState rewrites _NET_WM_STATE of r from its bits.
187+func (x *XWM) writeState(r *record) {
188+ var list [3]uint32
189+ n := 0
190+ if r.modal {
191+ list[n] = x.atoms[atomNetWMStateModal]
192+ n++
193+ }
194+ if r.stateSent&stateFullscreen != 0 {
195+ list[n] = x.atoms[atomNetWMStateFullscreen]
196+ n++
197+ }
198+ if r.stateSent&stateFocused != 0 {
199+ list[n] = x.atoms[atomNetWMStateFocused]
200+ n++
201+ }
202+ x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n])
203+}
204+
205+// sendConfigureNotify tells the client its geometry as the XWM sees it.
206+func (x *XWM) sendConfigureNotify(r *record) {
207+ var raw [32]byte
208+ d := xproto.ConfigureNotifyEventData{
209+ Event: xproto.Window(r.xid), Window: xproto.Window(r.xid),
210+ X: int16(min(max(r.x, math.MinInt16), math.MaxInt16)), Y: int16(min(max(r.y, math.MinInt16), math.MaxInt16)),
211+ Width: uint16(min(r.w, math.MaxUint16)), Height: uint16(min(r.h, math.MaxUint16)),
212+ }
213+ d.Put(&raw)
214+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw)
215+}
216+
217+// onConfigureRequest answers a client resizing itself. Tiled windows are told
218+// their current geometry; floating ones get the size they ask for; unmanaged
219+// and unknown windows are configured as requested.
220+func (x *XWM) onConfigureRequest(ev *xturbo.Event) {
221+ e := xproto.ConfigureRequestOf(ev)
222+ xid := uint32(e.Window())
223+ mask := e.ValueMask()
224+ s := x.tab.lookup(xid)
225+ r := x.managedRec(s)
226+ if r == nil {
227+ var v xproto.ConfigureWindowValues
228+ if mask&xproto.ConfigWindowX != 0 {
229+ v.SetX(int32(e.X()))
230+ }
231+ if mask&xproto.ConfigWindowY != 0 {
232+ v.SetY(int32(e.Y()))
233+ }
234+ if mask&xproto.ConfigWindowWidth != 0 {
235+ v.SetWidth(uint32(e.Width()))
236+ }
237+ if mask&xproto.ConfigWindowHeight != 0 {
238+ v.SetHeight(uint32(e.Height()))
239+ }
240+ if mask&xproto.ConfigWindowBorderWidth != 0 {
241+ v.SetBorderWidth(uint32(e.BorderWidth()))
242+ }
243+ if mask&xproto.ConfigWindowSibling != 0 {
244+ v.SetSibling(e.Sibling())
245+ }
246+ if mask&xproto.ConfigWindowStackMode != 0 {
247+ v.SetStackMode(e.StackMode())
248+ }
249+ xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v)
250+ return
251+ }
252+ if !r.ready {
253+ x.configureBeforeReady(s, r, e)
254+ return
255+ }
256+ if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
257+ w, h := r.w, r.h
258+ if mask&xproto.ConfigWindowWidth != 0 {
259+ w = uint32(min(max(int(e.Width()), 1), maxClientSize))
260+ }
261+ if mask&xproto.ConfigWindowHeight != 0 {
262+ h = uint32(min(max(int(e.Height()), 1), maxClientSize))
263+ }
264+ if w > 0 && h > 0 {
265+ var v xproto.ConfigureWindowValues
266+ v.SetWidth(w).SetHeight(h)
267+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0)
268+ r.w, r.h = w, h
269+ // The size is the client's own: core learns it from the commit
270+ // and Configure only resends it when core wants another.
271+ r.pend.w, r.pend.h = w, h
272+ r.sent.w, r.sent.h = w, h
273+ }
274+ }
275+ x.sendConfigureNotify(r)
276+}
277+
278+// configureBeforeReady honours the geometry a window asks for before it is
279+// ready (XMoveResize before XMapWindow), floating or not: core only sees the
280+// window at Ready and the client must find the geometry it set.
281+func (x *XWM) configureBeforeReady(s Slot, r *record, e xproto.ConfigureRequestEvent) {
282+ mask := e.ValueMask()
283+ var v xproto.ConfigureWindowValues
284+ if mask&xproto.ConfigWindowX != 0 {
285+ r.x = int32(e.X())
286+ v.SetX(r.x)
287+ }
288+ if mask&xproto.ConfigWindowY != 0 {
289+ r.y = int32(e.Y())
290+ v.SetY(r.y)
291+ }
292+ if mask&xproto.ConfigWindowWidth != 0 {
293+ r.w, r.sized = uint32(min(max(int(e.Width()), 1), maxClientSize)), true
294+ v.SetWidth(r.w)
295+ }
296+ if mask&xproto.ConfigWindowHeight != 0 {
297+ r.h, r.sized = uint32(min(max(int(e.Height()), 1), maxClientSize)), true
298+ v.SetHeight(r.h)
299+ }
300+ if mask&(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 {
301+ x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0)
302+ }
303+ x.sendConfigureNotify(r)
304+}
305+
306+// onConfigureNotify keeps the known geometry of every top-level window.
307+func (x *XWM) onConfigureNotify(ev *xturbo.Event) {
308+ e := xproto.ConfigureNotifyOf(ev)
309+ if e.Event() != xproto.Window(x.root) {
310+ return
311+ }
312+ if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree {
313+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
314+ if r.kind == kindUnmanaged && r.ovPaired {
315+ x.queueOverride(x.tab.lookup(r.xid), r)
316+ }
317+ }
318+}
A internal/adapters/wayland/xwm/configure_test.go +583 -0
@@ -0,0 +1,583 @@
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+}
566+
567+// Repeated identical fullscreen configures cost nothing.
568+func TestFullscreenConfigureAllocations(t *testing.T) {
569+ x, p, h, _, _ := attached(t)
570+ s := newWin(t, x, p, h, 0x500001)
571+ sinkPeer(t, x, p)
572+ cfg := ports.ConfigureWindow{Width: 1920, Height: 1080, Fullscreen: true, Activated: true}
573+ x.Configure(s, cfg)
574+ x.posted = false
575+ x.flush()
576+ allocs := testing.AllocsPerRun(300, func() {
577+ x.Configure(s, cfg)
578+ x.Place(s, 0, 0)
579+ x.posted = false
580+ x.flush()
581+ })
582+ require.Zero(t, allocs)
583+}
A internal/adapters/wayland/xwm/cursor.go +109 -0
@@ -0,0 +1,109 @@
1+package xwm
2+
3+import (
4+ "bnema.dev/xturbo"
5+ "bnema.dev/xturbo/render"
6+ "bnema.dev/xturbo/xproto"
7+)
8+
9+// Root cursor: a themed arrow on the root window so the X background does not
10+// show Xwayland's cross. One-shot at boot; never fatal.
11+
12+const maxCursorBytes = 1 << 18 // one PutImage request, below the core limit
13+
14+// bootRootCursor sets the cursor of the root window.
15+func (x *XWM) bootRootCursor() error {
16+ if !x.cfg.RootCursor {
17+ return nil
18+ }
19+ img := x.cfg.Cursor
20+ usable := img.W > 0 && img.H > 0 && img.W <= 0xffff && img.H <= 0xffff &&
21+ len(img.Pixels) == img.W*img.H*4 && len(img.Pixels) <= maxCursorBytes
22+ if usable && x.conn.Ext().Info(xturbo.ExtRender).Present {
23+ b := x.conn.Buf()
24+ x.expect(render.QueryPictFormats(b), kindPictFormats, 0, 0)
25+ if err := x.conn.Sync(&x.h); err != nil {
26+ return err
27+ }
28+ if x.bootErr != nil {
29+ return x.bootErr
30+ }
31+ if x.xs.argb != 0 && x.argbCursor(img.W, img.H, img.HotX, img.HotY, img.Pixels) {
32+ return nil
33+ }
34+ }
35+ x.glyphCursor()
36+ return nil
37+}
38+
39+// onPictFormatsReply remembers the ARGB32 picture format (depth 32, direct,
40+// alpha mask 0xff at shift 24).
41+func (x *XWM) onPictFormatsReply(r *xturbo.Reply) {
42+ v, ok := render.ParseQueryPictFormatsReply(r.Raw)
43+ if !ok {
44+ return
45+ }
46+ l := v.Formats()
47+ for i := 0; i < l.Len(); i++ {
48+ f := l.At(i)
49+ if f.Type == render.PictTypeDirect && f.Depth == 32 && f.Direct.AlphaMask == 0xff && f.Direct.AlphaShift == 24 &&
50+ f.Direct.RedMask == 0xff && f.Direct.RedShift == 16 {
51+ x.xs.argb = uint32(f.Id)
52+ return
53+ }
54+ }
55+}
56+
57+func (x *XWM) newIDs(ids []uint32) bool {
58+ for i := range ids {
59+ id, err := x.conn.NewXID()
60+ if err != nil {
61+ return false
62+ }
63+ ids[i] = id
64+ }
65+ return true
66+}
67+
68+// argbCursor uploads the image and installs it on the root.
69+func (x *XWM) argbCursor(w, h, hotX, hotY int, px []byte) bool {
70+ var id [4]uint32 // pixmap, gc, picture, cursor
71+ if !x.newIDs(id[:]) {
72+ return false
73+ }
74+ b := x.conn.Buf()
75+ pm, gc, pic, cur := xproto.Pixmap(id[0]), xproto.Gcontext(id[1]), render.Picture(id[2]), xproto.Cursor(id[3])
76+ xproto.CreatePixmap(b, 32, pm, xproto.Drawable(x.root), uint16(w), uint16(h))
77+ var gv xproto.CreateGCValues
78+ xproto.CreateGC(b, gc, xproto.Drawable(pm), &gv)
79+ xproto.PutImage(b, xproto.ImageFormatZPixmap, xproto.Drawable(pm), gc, uint16(w), uint16(h), 0, 0, 0, 32, px)
80+ var pv render.CreatePictureValues
81+ render.CreatePicture(b, pic, xproto.Drawable(pm), render.Pictformat(x.xs.argb), &pv)
82+ render.CreateCursor(b, cur, pic, uint16(min(max(hotX, 0), w-1)), uint16(min(max(hotY, 0), h-1)))
83+ var wv xproto.ChangeWindowAttributesValues
84+ wv.SetCursor(cur)
85+ xproto.ChangeWindowAttributes(b, xproto.Window(x.root), &wv)
86+ render.FreePicture(b, pic)
87+ xproto.FreeGC(b, gc)
88+ xproto.FreePixmap(b, pm)
89+ xproto.FreeCursor(b, cur)
90+ return true
91+}
92+
93+// glyphCursor installs the "left_ptr" glyph of the X cursor font (glyph 68,
94+// mask 69).
95+func (x *XWM) glyphCursor() {
96+ var id [2]uint32 // font, cursor
97+ if !x.newIDs(id[:]) {
98+ return
99+ }
100+ b := x.conn.Buf()
101+ font, cur := xproto.Font(id[0]), xproto.Cursor(id[1])
102+ xproto.OpenFont(b, font, "cursor")
103+ xproto.CreateGlyphCursor(b, cur, font, font, 68, 69, 0, 0, 0, 0xffff, 0xffff, 0xffff)
104+ var wv xproto.ChangeWindowAttributesValues
105+ wv.SetCursor(cur)
106+ xproto.ChangeWindowAttributes(b, xproto.Window(x.root), &wv)
107+ xproto.CloseFont(b, font)
108+ xproto.FreeCursor(b, cur)
109+}
A internal/adapters/wayland/xwm/cursor_test.go +86 -0
@@ -0,0 +1,86 @@
1+package xwm
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func rootCursorExts() map[string]xpeerExt {
11+ e := fullPeerExts()
12+ e["RENDER"] = xpeerExt{139, 0, 0}
13+ return e
14+}
15+
16+func bootCursor(t *testing.T, exts map[string]xpeerExt, mutate func(*xpeer), cfg ports.XwaylandSettings) (*XWM, *xpeer) {
17+ t.Helper()
18+ fd, p := startXPeer(t, exts)
19+ if mutate != nil {
20+ p.set(mutate)
21+ }
22+ x, err := BootSettings(t.Context(), fd, testLog(), cfg)
23+ require.NoError(t, err)
24+ t.Cleanup(x.CloseConn)
25+ return x, p
26+}
27+
28+func cursorCfg() ports.XwaylandSettings {
29+ return ports.XwaylandSettings{RootCursor: true, Cursor: ports.CursorImage{W: 2, H: 2, HotX: 1, HotY: 0, Pixels: make([]byte, 16)}}
30+}
31+
32+func TestRootCursorRequests(t *testing.T) {
33+ _, p := bootCursor(t, rootCursorExts(), nil, cursorCfg())
34+ // QueryPictFormats (render minor 1) comes first; then the core requests.
35+ var got []string
36+ for _, r := range p.requests() {
37+ switch {
38+ case r.major == 139 && r.minor == 1:
39+ got = append(got, "QueryPictFormats")
40+ case r.major == 53:
41+ got = append(got, "CreatePixmap")
42+ case r.major == 72:
43+ got = append(got, "PutImage")
44+ case r.major == 139 && r.minor == 4:
45+ got = append(got, "CreatePicture")
46+ case r.major == 139 && r.minor == 27:
47+ got = append(got, "CreateCursor")
48+ case r.major == opChangeWindowAttributes && r.u32(4) == peerRoot && r.u32(8)&(1<<14) != 0:
49+ got = append(got, "ChangeWindowAttributes(root)")
50+ case r.major == 54:
51+ got = append(got, "FreePixmap")
52+ }
53+ }
54+ require.Equal(t, []string{"QueryPictFormats", "CreatePixmap", "PutImage", "CreatePicture", "CreateCursor",
55+ "ChangeWindowAttributes(root)", "FreePixmap"}, got)
56+ for _, r := range p.requests() {
57+ if r.major == 72 { // PutImage: ZPixmap, depth 32
58+ require.EqualValues(t, 2, r.raw[1])
59+ require.EqualValues(t, 32, r.raw[21])
60+ }
61+ }
62+}
63+
64+func TestRootCursorFallbackWithoutRender(t *testing.T) {
65+ _, p := bootCursor(t, fullPeerExts(), nil, cursorCfg())
66+ var open, glyph, set bool
67+ for _, r := range p.requests() {
68+ open = open || r.major == 45
69+ glyph = glyph || r.major == 94
70+ set = set || r.major == opChangeWindowAttributes && r.u32(4) == peerRoot && r.u32(8)&(1<<14) != 0
71+ }
72+ require.True(t, open && glyph && set, "OpenFont, CreateGlyphCursor, root cursor")
73+ require.Zero(t, p.count(53, nil), "no pixmap")
74+}
75+
76+func TestRootCursorFallbackWithoutARGB(t *testing.T) {
77+ _, p := bootCursor(t, rootCursorExts(), func(p *xpeer) { p.noARGB = true }, cursorCfg())
78+ require.NotZero(t, p.count(94, nil))
79+ require.Zero(t, p.count(72, nil))
80+}
81+
82+func TestRootCursorOff(t *testing.T) {
83+ _, p := bootCursor(t, rootCursorExts(), nil, ports.XwaylandSettings{})
84+ require.Zero(t, p.count(94, nil))
85+ require.Zero(t, p.count(53, nil))
86+}
A internal/adapters/wayland/xwm/events.go +284 -0
@@ -0,0 +1,284 @@
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+ kindSelTargets
25+ kindSelData
26+ kindSelOut
27+ kindSelOwner
28+ kindPictFormats
29+)
30+
31+// onEvent dispatches an X event. Before Attach there is no host: only the
32+// table is maintained, and Attach replays the result.
33+func (x *XWM) onEvent(ev *xturbo.Event) {
34+ x.stats.Events++
35+ if x.closed {
36+ return
37+ }
38+ switch ev.Kind {
39+ case xproto.KindCreateNotify:
40+ x.onCreateNotify(ev)
41+ case xproto.KindDestroyNotify:
42+ x.onDestroyNotify(ev)
43+ case xproto.KindReparentNotify:
44+ x.onReparentNotify(ev)
45+ }
46+ if ev.Kind == xproto.KindSelectionClear {
47+ x.onXSettingsClear(ev)
48+ }
49+ if x.host == nil {
50+ x.onEventDetached(ev)
51+ return
52+ }
53+ switch ev.Kind {
54+ case xproto.KindMapRequest:
55+ x.onMapRequest(ev)
56+ case xproto.KindMapNotify:
57+ x.onMapNotify(ev)
58+ case xproto.KindUnmapNotify:
59+ x.onUnmapNotify(ev)
60+ case xproto.KindConfigureRequest:
61+ x.onConfigureRequest(ev)
62+ case xproto.KindConfigureNotify:
63+ x.onConfigureNotify(ev)
64+ case xproto.KindPropertyNotify:
65+ x.onPropertyNotify(ev)
66+ case xproto.KindClientMessage:
67+ x.onClientMessage(ev)
68+ case xproto.KindFocusIn:
69+ x.onFocusIn(ev)
70+ default:
71+ x.onSelectionEvent(ev)
72+ }
73+}
74+
75+// onEventDetached keeps what Attach replays: windows asked to map, and those
76+// withdrawn again, so Attach skips them; geometry requests are answered.
77+func (x *XWM) onEventDetached(ev *xturbo.Event) {
78+ switch ev.Kind {
79+ case xproto.KindMapRequest:
80+ if s := x.tab.lookup(uint32(xproto.MapRequestOf(ev).Window())); s != 0 {
81+ x.tab.rec(s).mapping = true
82+ }
83+ case xproto.KindUnmapNotify:
84+ if ev.SendEvent {
85+ return
86+ }
87+ if s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window())); s != 0 {
88+ r := x.tab.rec(s)
89+ r.mapped, r.mapping = false, false
90+ }
91+ case xproto.KindConfigureRequest:
92+ x.onConfigureRequest(ev)
93+ }
94+}
95+
96+func (x *XWM) onReply(r *xturbo.Reply) {
97+ x.stats.Replies++
98+ if x.closed {
99+ return
100+ }
101+ switch r.Kind {
102+ case kindVersion:
103+ x.onVersionReply(r)
104+ return
105+ case kindTree:
106+ x.onTreeReply(r)
107+ return
108+ case kindSelTargets:
109+ x.onTargetsReply(r)
110+ return
111+ case kindSelData:
112+ x.onSelDataReply(r)
113+ return
114+ case kindSelOwner:
115+ x.onSelOwnerReply(r)
116+ return
117+ case kindPictFormats:
118+ x.onPictFormatsReply(r)
119+ return
120+ }
121+ s, rec := x.tab.live(r.Args[0])
122+ if rec == nil {
123+ x.stats.Stale++
124+ return
125+ }
126+ switch r.Kind {
127+ case kindAttr:
128+ x.onAttrReply(rec, r)
129+ case kindProp:
130+ x.onPropReply(s, rec, r)
131+ case kindGeom:
132+ x.onGeomReply(s, rec, r)
133+ case kindClientIds:
134+ x.onClientIdsReply(s, rec, r)
135+ case kindOverrideType:
136+ x.onOverrideType(s, rec, r)
137+ }
138+}
139+
140+// onError never tears the XWM down: an error names a request, not the
141+// connection. Sticky Buf errors surface through Flush.
142+func (x *XWM) onError(e *xturbo.XError) {
143+ x.stats.Errors++
144+ if e.Kind == 0 {
145+ x.stats.Unattributed++
146+ x.log.Debug().Uint8("code", e.Code).Uint8("major", e.Major).Uint16("minor", e.Minor).
147+ Uint64("seq", uint64(e.Seq)).Msg("unattributed X error")
148+ return
149+ }
150+ switch e.Kind {
151+ case kindSelTargets, kindSelData, kindSelOut:
152+ x.onSelError(e)
153+ case kindRootMask:
154+ if e.Code == xproto.BadAccess {
155+ x.bootErr = ErrWMExists
156+ }
157+ case kindProp, kindGeom, kindClientIds, kindConfigure, kindFocus, kindOverrideType:
158+ // Usually BadWindow: the window died between request and reply and
159+ // DestroyNotify follows. Whatever the code, the awaited reply will
160+ // never come, so the map must not wait for it.
161+ s, rec := x.tab.live(e.Args[0])
162+ if rec == nil {
163+ x.stats.Stale++
164+ return
165+ }
166+ switch e.Kind {
167+ case kindProp:
168+ rec.pending &^= 1 << e.Args[1]
169+ case kindGeom:
170+ rec.pending &^= propGeom
171+ case kindClientIds:
172+ rec.pending &^= propClientIDs
173+ case kindOverrideType:
174+ if e.Code != xproto.BadWindow { // typed unknown: the window still shows
175+ x.overrideReady(s)
176+ }
177+ return
178+ default:
179+ return
180+ }
181+ if e.Code != xproto.BadWindow && e.Code != xproto.BadDrawable { // a dead window is never announced
182+ x.checkReady(s)
183+ }
184+ }
185+}
186+
187+// onCreateNotify records a new top-level window. Override-redirect windows
188+// are kept (their geometry and serial matter later) but never reach the host.
189+func (x *XWM) onCreateNotify(ev *xturbo.Event) {
190+ e := xproto.CreateNotifyOf(ev)
191+ xid := uint32(e.Window())
192+ if xid == x.wmWin || xid == x.selWin || x.tab.lookup(xid) != 0 {
193+ return
194+ }
195+ s := x.insert(xid)
196+ if s == 0 {
197+ return
198+ }
199+ r := x.tab.rec(s)
200+ override := e.OverrideRedirect()
201+ r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height())
202+ if override {
203+ r.kind = kindUnmanaged
204+ x.stats.UnmanagedSeen++
205+ return
206+ }
207+ if x.host != nil {
208+ x.adopt(s)
209+ }
210+}
211+
212+// adopt announces a managed window to the host and starts watching it.
213+func (x *XWM) adopt(s Slot) {
214+ r := x.tab.rec(s)
215+ r.id = x.host.Created(s, r.xid)
216+ var v xproto.ChangeWindowAttributesValues
217+ v.SetEventMask(xproto.EventMaskPropertyChange | xproto.EventMaskFocusChange)
218+ x.expect(xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(r.xid), &v), kindAttr, x.tab.tag(s), 0)
219+ x.log.Debug().Uint32("xid", r.xid).Uint32("slot", uint32(s)).Msg("x11 window created")
220+}
221+
222+func (x *XWM) onDestroyNotify(ev *xturbo.Event) {
223+ xid := uint32(xproto.DestroyNotifyOf(ev).Window())
224+ s := x.tab.lookup(xid)
225+ if s == 0 {
226+ return
227+ }
228+ x.forgetWindow(s)
229+}
230+
231+// onTreeReply (boot): record every existing child and ask for its attributes.
232+func (x *XWM) onTreeReply(r *xturbo.Reply) {
233+ q, ok := xproto.ParseQueryTreeReply(r.Raw)
234+ if !ok {
235+ return
236+ }
237+ children := q.Children()
238+ for i := 0; i < children.Len(); i++ {
239+ xid := uint32(children.At(i))
240+ if xid == x.wmWin || xid == x.selWin || x.tab.lookup(xid) != 0 {
241+ continue
242+ }
243+ s := x.insert(xid)
244+ if s == 0 {
245+ return
246+ }
247+ x.expect(xproto.GetWindowAttributes(x.conn.Buf(), xproto.Window(xid)), kindAttr, x.tab.tag(s), 0)
248+ }
249+}
250+
251+// onAttrReply (boot): classify an existing window; viewable ones go through
252+// the map flow once the host is attached.
253+func (x *XWM) onAttrReply(rec *record, r *xturbo.Reply) {
254+ a, ok := xproto.ParseGetWindowAttributesReply(r.Raw)
255+ if !ok || x.host != nil {
256+ return
257+ }
258+ if a.OverrideRedirect() {
259+ rec.kind = kindUnmanaged
260+ rec.mapped = a.MapState() == xproto.MapStateViewable
261+ x.stats.UnmanagedSeen++
262+ return
263+ }
264+ if a.MapState() == xproto.MapStateViewable {
265+ rec.mapped, rec.mapping = true, true
266+ }
267+}
268+
269+func (x *XWM) onVersionReply(r *xturbo.Reply) {
270+ switch r.Args[0] {
271+ case versionComposite:
272+ if v, ok := composite.ParseQueryVersionReply(r.Raw); ok {
273+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("Composite")
274+ }
275+ case versionXFixes:
276+ if v, ok := xfixes.ParseQueryVersionReply(r.Raw); ok {
277+ x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("XFixes")
278+ }
279+ case versionRes:
280+ if v, ok := res.ParseQueryVersionReply(r.Raw); ok {
281+ x.log.Info().Uint16("major", v.ServerMajor()).Uint16("minor", v.ServerMinor()).Msg("X-Resource")
282+ }
283+ }
284+}
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 +82 -0
@@ -0,0 +1,82 @@
1+package xwm
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// Slot is the XWM's window slot: table index + 1, stable until the window is
10+// destroyed. 0 means none.
11+type Slot uint32
12+
13+// WindowInfo is what the XWM learned about a managed window by the time it is
14+// ready to be mapped. The host copies it.
15+type WindowInfo struct {
16+ PID int
17+ Floating bool // dialog/utility/splash/fixed size
18+ Width, Height int // requested size, logical (scale 1)
19+ Parent Slot
20+ Modal bool // _NET_WM_STATE_MODAL
21+ Fullscreen bool // _NET_WM_STATE_FULLSCREEN at map
22+ Bypass uint8 // _NET_WM_BYPASS_COMPOSITOR (0 unset, 1 bypass, 2 do not)
23+ // HasAt: the client asked for a rect equal to a whole output; AtX, AtY
24+ // is that output's layout origin.
25+ HasAt bool
26+ AtX, AtY int
27+ Title, AppID string
28+}
29+
30+// host is the wayland server as the XWM sees it. Display goroutine only.
31+// All strings passed are owned by the XWM record and already deduplicated
32+// (only passed when changed).
33+type host interface {
34+ // Created: a managed (non-override-redirect) X window is known; the host
35+ // allocates a ports.WindowID and returns it. Not yet mapped.
36+ Created(slot Slot, xid uint32) ports.WindowID
37+ // Ready: properties read and MapWindow issued; the surface may still be
38+ // missing. Info is copied by the host.
39+ Ready(slot Slot, info WindowInfo)
40+ // Associate links the X window to the wl_surface with identity surfaceID.
41+ // The host maps the window on the surface's first buffer commit.
42+ Associate(slot Slot, surfaceID uint64)
43+ // Dissociate: UnmapNotify/DestroyNotify; the host emits WindowUnmapped if
44+ // mapped and drops the surface link. destroyed also frees the WindowID.
45+ Dissociate(slot Slot, destroyed bool)
46+ Title(slot Slot, title string) // changed only
47+ AppID(slot Slot, appID string) // changed only (WM_CLASS class)
48+ Parent(slot Slot, parent Slot) // 0 = none; after a WM_TRANSIENT_FOR change
49+ // Fullscreen forwards a fullscreen request (_NET_WM_STATE client message
50+ // or initial state); the host emits WindowFullscreenRequest.
51+ Fullscreen(slot Slot, on bool)
52+
53+ // OverrideMapped reports an override-redirect window that is mapped,
54+ // typed and paired with its wl_surface. Rect is in layout coordinates.
55+ OverrideMapped(slot Slot, xid uint32, surfaceID uint64, x, y, w, h int, noInput bool)
56+ // OverrideMoved reports a changed layout rect of a mapped override window.
57+ OverrideMoved(slot Slot, x, y, w, h int)
58+ // OverrideGone ends a mapped override window: unmapped or destroyed.
59+ OverrideGone(slot Slot)
60+
61+ // SelectionOwned: an X client owns kind (SelClipboard/SelPrimary) and
62+ // offers these MIME types, or none does any more (mimes nil). mimes is
63+ // scratch: the host copies it.
64+ SelectionOwned(kind int, mimes []string)
65+
66+ // SelectionRead asks for the data of the current Wayland selection kind
67+ // as mime: it returns a non-blocking read end the XWM closes, or -1 when
68+ // there is none, it is protected, or mime is not offered.
69+ SelectionRead(kind int, mime string) (fd int)
70+
71+ // After runs fn on the display goroutine once d has elapsed, unless the
72+ // returned cancel ran first. cancel is idempotent and display goroutine
73+ // only; fn may still run once after cancel when it was already queued,
74+ // so callers guard with their own generation.
75+ After(d time.Duration, fn func()) (cancel func())
76+}
77+
78+// fdSource is the display's event source for the X socket (*server.Source).
79+type fdSource interface {
80+ SetWritable(on bool) error
81+ Remove()
82+}
A internal/adapters/wayland/xwm/host_mock_test.go +775 -0
@@ -0,0 +1,775 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwm
6+
7+import (
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// 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.
15+// The first argument is typically a *testing.T value.
16+func newMockhost(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *mockhost {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &mockhost{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// mockhost is an autogenerated mock type for the host type
38+type mockhost struct {
39+ mock.Mock
40+}
41+
42+type mockhost_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *mockhost) EXPECT() *mockhost_Expecter {
47+ return &mockhost_Expecter{mock: &_m.Mock}
48+}
49+
50+// After provides a mock function for the type mockhost
51+func (_mock *mockhost) After(d time.Duration, fn func()) func() {
52+ ret := _mock.Called(d, fn)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for After")
56+ }
57+
58+ var r0 func()
59+ if returnFunc, ok := ret.Get(0).(func(time.Duration, func()) func()); ok {
60+ r0 = returnFunc(d, fn)
61+ } else {
62+ if ret.Get(0) != nil {
63+ r0 = ret.Get(0).(func())
64+ }
65+ }
66+ return r0
67+}
68+
69+// mockhost_After_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'After'
70+type mockhost_After_Call struct {
71+ *mock.Call
72+}
73+
74+// After is a helper method to define mock.On call
75+// - d time.Duration
76+// - fn func()
77+func (_e *mockhost_Expecter) After(d any, fn any) *mockhost_After_Call {
78+ return &mockhost_After_Call{Call: _e.mock.On("After", d, fn)}
79+}
80+
81+func (_c *mockhost_After_Call) Run(run func(d time.Duration, fn func())) *mockhost_After_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 time.Duration
84+ if args[0] != nil {
85+ arg0 = args[0].(time.Duration)
86+ }
87+ var arg1 func()
88+ if args[1] != nil {
89+ arg1 = args[1].(func())
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *mockhost_After_Call) Return(cancel func()) *mockhost_After_Call {
100+ _c.Call.Return(cancel)
101+ return _c
102+}
103+
104+func (_c *mockhost_After_Call) RunAndReturn(run func(d time.Duration, fn func()) func()) *mockhost_After_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// AppID provides a mock function for the type mockhost
110+func (_mock *mockhost) AppID(slot Slot, appID string) {
111+ _mock.Called(slot, appID)
112+ return
113+}
114+
115+// mockhost_AppID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppID'
116+type mockhost_AppID_Call struct {
117+ *mock.Call
118+}
119+
120+// AppID is a helper method to define mock.On call
121+// - slot Slot
122+// - appID string
123+func (_e *mockhost_Expecter) AppID(slot any, appID any) *mockhost_AppID_Call {
124+ return &mockhost_AppID_Call{Call: _e.mock.On("AppID", slot, appID)}
125+}
126+
127+func (_c *mockhost_AppID_Call) Run(run func(slot Slot, appID string)) *mockhost_AppID_Call {
128+ _c.Call.Run(func(args mock.Arguments) {
129+ var arg0 Slot
130+ if args[0] != nil {
131+ arg0 = args[0].(Slot)
132+ }
133+ var arg1 string
134+ if args[1] != nil {
135+ arg1 = args[1].(string)
136+ }
137+ run(
138+ arg0,
139+ arg1,
140+ )
141+ })
142+ return _c
143+}
144+
145+func (_c *mockhost_AppID_Call) Return() *mockhost_AppID_Call {
146+ _c.Call.Return()
147+ return _c
148+}
149+
150+func (_c *mockhost_AppID_Call) RunAndReturn(run func(slot Slot, appID string)) *mockhost_AppID_Call {
151+ _c.Run(run)
152+ return _c
153+}
154+
155+// Associate provides a mock function for the type mockhost
156+func (_mock *mockhost) Associate(slot Slot, surfaceID uint64) {
157+ _mock.Called(slot, surfaceID)
158+ return
159+}
160+
161+// mockhost_Associate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Associate'
162+type mockhost_Associate_Call struct {
163+ *mock.Call
164+}
165+
166+// Associate is a helper method to define mock.On call
167+// - slot Slot
168+// - surfaceID uint64
169+func (_e *mockhost_Expecter) Associate(slot any, surfaceID any) *mockhost_Associate_Call {
170+ return &mockhost_Associate_Call{Call: _e.mock.On("Associate", slot, surfaceID)}
171+}
172+
173+func (_c *mockhost_Associate_Call) Run(run func(slot Slot, surfaceID uint64)) *mockhost_Associate_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 Slot
176+ if args[0] != nil {
177+ arg0 = args[0].(Slot)
178+ }
179+ var arg1 uint64
180+ if args[1] != nil {
181+ arg1 = args[1].(uint64)
182+ }
183+ run(
184+ arg0,
185+ arg1,
186+ )
187+ })
188+ return _c
189+}
190+
191+func (_c *mockhost_Associate_Call) Return() *mockhost_Associate_Call {
192+ _c.Call.Return()
193+ return _c
194+}
195+
196+func (_c *mockhost_Associate_Call) RunAndReturn(run func(slot Slot, surfaceID uint64)) *mockhost_Associate_Call {
197+ _c.Run(run)
198+ return _c
199+}
200+
201+// Created provides a mock function for the type mockhost
202+func (_mock *mockhost) Created(slot Slot, xid uint32) ports.WindowID {
203+ ret := _mock.Called(slot, xid)
204+
205+ if len(ret) == 0 {
206+ panic("no return value specified for Created")
207+ }
208+
209+ var r0 ports.WindowID
210+ if returnFunc, ok := ret.Get(0).(func(Slot, uint32) ports.WindowID); ok {
211+ r0 = returnFunc(slot, xid)
212+ } else {
213+ r0 = ret.Get(0).(ports.WindowID)
214+ }
215+ return r0
216+}
217+
218+// mockhost_Created_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Created'
219+type mockhost_Created_Call struct {
220+ *mock.Call
221+}
222+
223+// Created is a helper method to define mock.On call
224+// - slot Slot
225+// - xid uint32
226+func (_e *mockhost_Expecter) Created(slot any, xid any) *mockhost_Created_Call {
227+ return &mockhost_Created_Call{Call: _e.mock.On("Created", slot, xid)}
228+}
229+
230+func (_c *mockhost_Created_Call) Run(run func(slot Slot, xid uint32)) *mockhost_Created_Call {
231+ _c.Call.Run(func(args mock.Arguments) {
232+ var arg0 Slot
233+ if args[0] != nil {
234+ arg0 = args[0].(Slot)
235+ }
236+ var arg1 uint32
237+ if args[1] != nil {
238+ arg1 = args[1].(uint32)
239+ }
240+ run(
241+ arg0,
242+ arg1,
243+ )
244+ })
245+ return _c
246+}
247+
248+func (_c *mockhost_Created_Call) Return(windowID ports.WindowID) *mockhost_Created_Call {
249+ _c.Call.Return(windowID)
250+ return _c
251+}
252+
253+func (_c *mockhost_Created_Call) RunAndReturn(run func(slot Slot, xid uint32) ports.WindowID) *mockhost_Created_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
258+// Dissociate provides a mock function for the type mockhost
259+func (_mock *mockhost) Dissociate(slot Slot, destroyed bool) {
260+ _mock.Called(slot, destroyed)
261+ return
262+}
263+
264+// mockhost_Dissociate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dissociate'
265+type mockhost_Dissociate_Call struct {
266+ *mock.Call
267+}
268+
269+// Dissociate is a helper method to define mock.On call
270+// - slot Slot
271+// - destroyed bool
272+func (_e *mockhost_Expecter) Dissociate(slot any, destroyed any) *mockhost_Dissociate_Call {
273+ return &mockhost_Dissociate_Call{Call: _e.mock.On("Dissociate", slot, destroyed)}
274+}
275+
276+func (_c *mockhost_Dissociate_Call) Run(run func(slot Slot, destroyed bool)) *mockhost_Dissociate_Call {
277+ _c.Call.Run(func(args mock.Arguments) {
278+ var arg0 Slot
279+ if args[0] != nil {
280+ arg0 = args[0].(Slot)
281+ }
282+ var arg1 bool
283+ if args[1] != nil {
284+ arg1 = args[1].(bool)
285+ }
286+ run(
287+ arg0,
288+ arg1,
289+ )
290+ })
291+ return _c
292+}
293+
294+func (_c *mockhost_Dissociate_Call) Return() *mockhost_Dissociate_Call {
295+ _c.Call.Return()
296+ return _c
297+}
298+
299+func (_c *mockhost_Dissociate_Call) RunAndReturn(run func(slot Slot, destroyed bool)) *mockhost_Dissociate_Call {
300+ _c.Run(run)
301+ return _c
302+}
303+
304+// Fullscreen provides a mock function for the type mockhost
305+func (_mock *mockhost) Fullscreen(slot Slot, on bool) {
306+ _mock.Called(slot, on)
307+ return
308+}
309+
310+// mockhost_Fullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fullscreen'
311+type mockhost_Fullscreen_Call struct {
312+ *mock.Call
313+}
314+
315+// Fullscreen is a helper method to define mock.On call
316+// - slot Slot
317+// - on bool
318+func (_e *mockhost_Expecter) Fullscreen(slot any, on any) *mockhost_Fullscreen_Call {
319+ return &mockhost_Fullscreen_Call{Call: _e.mock.On("Fullscreen", slot, on)}
320+}
321+
322+func (_c *mockhost_Fullscreen_Call) Run(run func(slot Slot, on bool)) *mockhost_Fullscreen_Call {
323+ _c.Call.Run(func(args mock.Arguments) {
324+ var arg0 Slot
325+ if args[0] != nil {
326+ arg0 = args[0].(Slot)
327+ }
328+ var arg1 bool
329+ if args[1] != nil {
330+ arg1 = args[1].(bool)
331+ }
332+ run(
333+ arg0,
334+ arg1,
335+ )
336+ })
337+ return _c
338+}
339+
340+func (_c *mockhost_Fullscreen_Call) Return() *mockhost_Fullscreen_Call {
341+ _c.Call.Return()
342+ return _c
343+}
344+
345+func (_c *mockhost_Fullscreen_Call) RunAndReturn(run func(slot Slot, on bool)) *mockhost_Fullscreen_Call {
346+ _c.Run(run)
347+ return _c
348+}
349+
350+// OverrideGone provides a mock function for the type mockhost
351+func (_mock *mockhost) OverrideGone(slot Slot) {
352+ _mock.Called(slot)
353+ return
354+}
355+
356+// mockhost_OverrideGone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideGone'
357+type mockhost_OverrideGone_Call struct {
358+ *mock.Call
359+}
360+
361+// OverrideGone is a helper method to define mock.On call
362+// - slot Slot
363+func (_e *mockhost_Expecter) OverrideGone(slot any) *mockhost_OverrideGone_Call {
364+ return &mockhost_OverrideGone_Call{Call: _e.mock.On("OverrideGone", slot)}
365+}
366+
367+func (_c *mockhost_OverrideGone_Call) Run(run func(slot Slot)) *mockhost_OverrideGone_Call {
368+ _c.Call.Run(func(args mock.Arguments) {
369+ var arg0 Slot
370+ if args[0] != nil {
371+ arg0 = args[0].(Slot)
372+ }
373+ run(
374+ arg0,
375+ )
376+ })
377+ return _c
378+}
379+
380+func (_c *mockhost_OverrideGone_Call) Return() *mockhost_OverrideGone_Call {
381+ _c.Call.Return()
382+ return _c
383+}
384+
385+func (_c *mockhost_OverrideGone_Call) RunAndReturn(run func(slot Slot)) *mockhost_OverrideGone_Call {
386+ _c.Run(run)
387+ return _c
388+}
389+
390+// OverrideMapped provides a mock function for the type mockhost
391+func (_mock *mockhost) OverrideMapped(slot Slot, xid uint32, surfaceID uint64, x int, y int, w int, h int, noInput bool) {
392+ _mock.Called(slot, xid, surfaceID, x, y, w, h, noInput)
393+ return
394+}
395+
396+// mockhost_OverrideMapped_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideMapped'
397+type mockhost_OverrideMapped_Call struct {
398+ *mock.Call
399+}
400+
401+// OverrideMapped is a helper method to define mock.On call
402+// - slot Slot
403+// - xid uint32
404+// - surfaceID uint64
405+// - x int
406+// - y int
407+// - w int
408+// - h int
409+// - noInput bool
410+func (_e *mockhost_Expecter) OverrideMapped(slot any, xid any, surfaceID any, x any, y any, w any, h any, noInput any) *mockhost_OverrideMapped_Call {
411+ return &mockhost_OverrideMapped_Call{Call: _e.mock.On("OverrideMapped", slot, xid, surfaceID, x, y, w, h, noInput)}
412+}
413+
414+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 {
415+ _c.Call.Run(func(args mock.Arguments) {
416+ var arg0 Slot
417+ if args[0] != nil {
418+ arg0 = args[0].(Slot)
419+ }
420+ var arg1 uint32
421+ if args[1] != nil {
422+ arg1 = args[1].(uint32)
423+ }
424+ var arg2 uint64
425+ if args[2] != nil {
426+ arg2 = args[2].(uint64)
427+ }
428+ var arg3 int
429+ if args[3] != nil {
430+ arg3 = args[3].(int)
431+ }
432+ var arg4 int
433+ if args[4] != nil {
434+ arg4 = args[4].(int)
435+ }
436+ var arg5 int
437+ if args[5] != nil {
438+ arg5 = args[5].(int)
439+ }
440+ var arg6 int
441+ if args[6] != nil {
442+ arg6 = args[6].(int)
443+ }
444+ var arg7 bool
445+ if args[7] != nil {
446+ arg7 = args[7].(bool)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ arg3,
453+ arg4,
454+ arg5,
455+ arg6,
456+ arg7,
457+ )
458+ })
459+ return _c
460+}
461+
462+func (_c *mockhost_OverrideMapped_Call) Return() *mockhost_OverrideMapped_Call {
463+ _c.Call.Return()
464+ return _c
465+}
466+
467+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 {
468+ _c.Run(run)
469+ return _c
470+}
471+
472+// OverrideMoved provides a mock function for the type mockhost
473+func (_mock *mockhost) OverrideMoved(slot Slot, x int, y int, w int, h int) {
474+ _mock.Called(slot, x, y, w, h)
475+ return
476+}
477+
478+// mockhost_OverrideMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OverrideMoved'
479+type mockhost_OverrideMoved_Call struct {
480+ *mock.Call
481+}
482+
483+// OverrideMoved is a helper method to define mock.On call
484+// - slot Slot
485+// - x int
486+// - y int
487+// - w int
488+// - h int
489+func (_e *mockhost_Expecter) OverrideMoved(slot any, x any, y any, w any, h any) *mockhost_OverrideMoved_Call {
490+ return &mockhost_OverrideMoved_Call{Call: _e.mock.On("OverrideMoved", slot, x, y, w, h)}
491+}
492+
493+func (_c *mockhost_OverrideMoved_Call) Run(run func(slot Slot, x int, y int, w int, h int)) *mockhost_OverrideMoved_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 int
500+ if args[1] != nil {
501+ arg1 = args[1].(int)
502+ }
503+ var arg2 int
504+ if args[2] != nil {
505+ arg2 = args[2].(int)
506+ }
507+ var arg3 int
508+ if args[3] != nil {
509+ arg3 = args[3].(int)
510+ }
511+ var arg4 int
512+ if args[4] != nil {
513+ arg4 = args[4].(int)
514+ }
515+ run(
516+ arg0,
517+ arg1,
518+ arg2,
519+ arg3,
520+ arg4,
521+ )
522+ })
523+ return _c
524+}
525+
526+func (_c *mockhost_OverrideMoved_Call) Return() *mockhost_OverrideMoved_Call {
527+ _c.Call.Return()
528+ return _c
529+}
530+
531+func (_c *mockhost_OverrideMoved_Call) RunAndReturn(run func(slot Slot, x int, y int, w int, h int)) *mockhost_OverrideMoved_Call {
532+ _c.Run(run)
533+ return _c
534+}
535+
536+// Parent provides a mock function for the type mockhost
537+func (_mock *mockhost) Parent(slot Slot, parent Slot) {
538+ _mock.Called(slot, parent)
539+ return
540+}
541+
542+// mockhost_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent'
543+type mockhost_Parent_Call struct {
544+ *mock.Call
545+}
546+
547+// Parent is a helper method to define mock.On call
548+// - slot Slot
549+// - parent Slot
550+func (_e *mockhost_Expecter) Parent(slot any, parent any) *mockhost_Parent_Call {
551+ return &mockhost_Parent_Call{Call: _e.mock.On("Parent", slot, parent)}
552+}
553+
554+func (_c *mockhost_Parent_Call) Run(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
555+ _c.Call.Run(func(args mock.Arguments) {
556+ var arg0 Slot
557+ if args[0] != nil {
558+ arg0 = args[0].(Slot)
559+ }
560+ var arg1 Slot
561+ if args[1] != nil {
562+ arg1 = args[1].(Slot)
563+ }
564+ run(
565+ arg0,
566+ arg1,
567+ )
568+ })
569+ return _c
570+}
571+
572+func (_c *mockhost_Parent_Call) Return() *mockhost_Parent_Call {
573+ _c.Call.Return()
574+ return _c
575+}
576+
577+func (_c *mockhost_Parent_Call) RunAndReturn(run func(slot Slot, parent Slot)) *mockhost_Parent_Call {
578+ _c.Run(run)
579+ return _c
580+}
581+
582+// Ready provides a mock function for the type mockhost
583+func (_mock *mockhost) Ready(slot Slot, info WindowInfo) {
584+ _mock.Called(slot, info)
585+ return
586+}
587+
588+// mockhost_Ready_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ready'
589+type mockhost_Ready_Call struct {
590+ *mock.Call
591+}
592+
593+// Ready is a helper method to define mock.On call
594+// - slot Slot
595+// - info WindowInfo
596+func (_e *mockhost_Expecter) Ready(slot any, info any) *mockhost_Ready_Call {
597+ return &mockhost_Ready_Call{Call: _e.mock.On("Ready", slot, info)}
598+}
599+
600+func (_c *mockhost_Ready_Call) Run(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
601+ _c.Call.Run(func(args mock.Arguments) {
602+ var arg0 Slot
603+ if args[0] != nil {
604+ arg0 = args[0].(Slot)
605+ }
606+ var arg1 WindowInfo
607+ if args[1] != nil {
608+ arg1 = args[1].(WindowInfo)
609+ }
610+ run(
611+ arg0,
612+ arg1,
613+ )
614+ })
615+ return _c
616+}
617+
618+func (_c *mockhost_Ready_Call) Return() *mockhost_Ready_Call {
619+ _c.Call.Return()
620+ return _c
621+}
622+
623+func (_c *mockhost_Ready_Call) RunAndReturn(run func(slot Slot, info WindowInfo)) *mockhost_Ready_Call {
624+ _c.Run(run)
625+ return _c
626+}
627+
628+// SelectionOwned provides a mock function for the type mockhost
629+func (_mock *mockhost) SelectionOwned(kind int, mimes []string) {
630+ _mock.Called(kind, mimes)
631+ return
632+}
633+
634+// mockhost_SelectionOwned_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionOwned'
635+type mockhost_SelectionOwned_Call struct {
636+ *mock.Call
637+}
638+
639+// SelectionOwned is a helper method to define mock.On call
640+// - kind int
641+// - mimes []string
642+func (_e *mockhost_Expecter) SelectionOwned(kind any, mimes any) *mockhost_SelectionOwned_Call {
643+ return &mockhost_SelectionOwned_Call{Call: _e.mock.On("SelectionOwned", kind, mimes)}
644+}
645+
646+func (_c *mockhost_SelectionOwned_Call) Run(run func(kind int, mimes []string)) *mockhost_SelectionOwned_Call {
647+ _c.Call.Run(func(args mock.Arguments) {
648+ var arg0 int
649+ if args[0] != nil {
650+ arg0 = args[0].(int)
651+ }
652+ var arg1 []string
653+ if args[1] != nil {
654+ arg1 = args[1].([]string)
655+ }
656+ run(
657+ arg0,
658+ arg1,
659+ )
660+ })
661+ return _c
662+}
663+
664+func (_c *mockhost_SelectionOwned_Call) Return() *mockhost_SelectionOwned_Call {
665+ _c.Call.Return()
666+ return _c
667+}
668+
669+func (_c *mockhost_SelectionOwned_Call) RunAndReturn(run func(kind int, mimes []string)) *mockhost_SelectionOwned_Call {
670+ _c.Run(run)
671+ return _c
672+}
673+
674+// SelectionRead provides a mock function for the type mockhost
675+func (_mock *mockhost) SelectionRead(kind int, mime string) int {
676+ ret := _mock.Called(kind, mime)
677+
678+ if len(ret) == 0 {
679+ panic("no return value specified for SelectionRead")
680+ }
681+
682+ var r0 int
683+ if returnFunc, ok := ret.Get(0).(func(int, string) int); ok {
684+ r0 = returnFunc(kind, mime)
685+ } else {
686+ r0 = ret.Get(0).(int)
687+ }
688+ return r0
689+}
690+
691+// mockhost_SelectionRead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionRead'
692+type mockhost_SelectionRead_Call struct {
693+ *mock.Call
694+}
695+
696+// SelectionRead is a helper method to define mock.On call
697+// - kind int
698+// - mime string
699+func (_e *mockhost_Expecter) SelectionRead(kind any, mime any) *mockhost_SelectionRead_Call {
700+ return &mockhost_SelectionRead_Call{Call: _e.mock.On("SelectionRead", kind, mime)}
701+}
702+
703+func (_c *mockhost_SelectionRead_Call) Run(run func(kind int, mime string)) *mockhost_SelectionRead_Call {
704+ _c.Call.Run(func(args mock.Arguments) {
705+ var arg0 int
706+ if args[0] != nil {
707+ arg0 = args[0].(int)
708+ }
709+ var arg1 string
710+ if args[1] != nil {
711+ arg1 = args[1].(string)
712+ }
713+ run(
714+ arg0,
715+ arg1,
716+ )
717+ })
718+ return _c
719+}
720+
721+func (_c *mockhost_SelectionRead_Call) Return(fd int) *mockhost_SelectionRead_Call {
722+ _c.Call.Return(fd)
723+ return _c
724+}
725+
726+func (_c *mockhost_SelectionRead_Call) RunAndReturn(run func(kind int, mime string) int) *mockhost_SelectionRead_Call {
727+ _c.Call.Return(run)
728+ return _c
729+}
730+
731+// Title provides a mock function for the type mockhost
732+func (_mock *mockhost) Title(slot Slot, title string) {
733+ _mock.Called(slot, title)
734+ return
735+}
736+
737+// mockhost_Title_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Title'
738+type mockhost_Title_Call struct {
739+ *mock.Call
740+}
741+
742+// Title is a helper method to define mock.On call
743+// - slot Slot
744+// - title string
745+func (_e *mockhost_Expecter) Title(slot any, title any) *mockhost_Title_Call {
746+ return &mockhost_Title_Call{Call: _e.mock.On("Title", slot, title)}
747+}
748+
749+func (_c *mockhost_Title_Call) Run(run func(slot Slot, title string)) *mockhost_Title_Call {
750+ _c.Call.Run(func(args mock.Arguments) {
751+ var arg0 Slot
752+ if args[0] != nil {
753+ arg0 = args[0].(Slot)
754+ }
755+ var arg1 string
756+ if args[1] != nil {
757+ arg1 = args[1].(string)
758+ }
759+ run(
760+ arg0,
761+ arg1,
762+ )
763+ })
764+ return _c
765+}
766+
767+func (_c *mockhost_Title_Call) Return() *mockhost_Title_Call {
768+ _c.Call.Return()
769+ return _c
770+}
771+
772+func (_c *mockhost_Title_Call) RunAndReturn(run func(slot Slot, title string)) *mockhost_Title_Call {
773+ _c.Run(run)
774+ return _c
775+}
A internal/adapters/wayland/xwm/mapflow.go +359 -0
@@ -0,0 +1,359 @@
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+ Bypass: rec.bypass,
146+ }
147+ if rec.sized && !info.Floating {
148+ info.AtX, info.AtY, info.HasAt = x.space.WholeOutput(int(rec.x), int(rec.y), int(rec.w), int(rec.h))
149+ }
150+ if info.Floating || rec.sized {
151+ info.Width, info.Height = int(rec.w), int(rec.h)
152+ }
153+ return info
154+}
155+
156+func (x *XWM) onGeomReply(s Slot, rec *record, r *xturbo.Reply) {
157+ if g, ok := xproto.ParseGetGeometryReply(r.Raw); ok {
158+ rec.x, rec.y, rec.w, rec.h = int32(g.X()), int32(g.Y()), uint32(g.Width()), uint32(g.Height())
159+ }
160+ rec.pending &^= propGeom
161+ x.checkReady(s)
162+}
163+
164+// onClientMessage handles WL_SURFACE_SERIAL (pairing), the legacy
165+// WL_SURFACE_ID (counted, ignored) and _NET_WM_STATE requests.
166+func (x *XWM) onClientMessage(ev *xturbo.Event) {
167+ e := xproto.ClientMessageOf(ev)
168+ if e.Format() != 32 {
169+ return
170+ }
171+ typ := uint32(e.Type())
172+ switch typ {
173+ case x.atoms[atomWLSurfaceSerial]:
174+ s := x.tab.lookup(uint32(e.Window()))
175+ if s == 0 {
176+ return
177+ }
178+ rec := x.tab.rec(s)
179+ if rec.serial != 0 {
180+ x.log.Debug().Uint32("xid", rec.xid).Msg("duplicate WL_SURFACE_SERIAL ignored")
181+ return
182+ }
183+ d := e.Data().Data32()
184+ rec.serial = uint64(d[1])<<32 | uint64(d[0])
185+ x.tryPair(s)
186+ case x.atoms[atomWLSurfaceID]:
187+ x.stats.LegacyID++
188+ if x.stats.LegacyID == 1 {
189+ x.log.Warn().Msg("WL_SURFACE_ID received: Xwayland did not bind xwayland_shell_v1, window ignored")
190+ }
191+ case x.atoms[atomNetWMState]:
192+ x.onNetWMState(e)
193+ case x.atoms[atomWMChangeState]:
194+ // Iconify requests are ignored: windows stay mapped.
195+ }
196+}
197+
198+// onNetWMState forwards fullscreen requests to the host and records them; the
199+// property itself is written when core answers with a configure.
200+func (x *XWM) onNetWMState(e xproto.ClientMessageEvent) {
201+ s := x.tab.lookup(uint32(e.Window()))
202+ if s == 0 {
203+ return
204+ }
205+ rec := x.tab.rec(s)
206+ if rec.kind != kindManaged {
207+ return
208+ }
209+ d := e.Data().Data32()
210+ apply := func(cur bool) bool {
211+ switch d[0] {
212+ case netStateRemove:
213+ return false
214+ case netStateAdd:
215+ return true
216+ case netStateToggle:
217+ return !cur
218+ }
219+ return cur
220+ }
221+ for _, a := range d[1:3] {
222+ switch {
223+ case a == 0:
224+ case a == x.atoms[atomNetWMStateModal]:
225+ rec.modal = apply(rec.modal)
226+ case a == x.atoms[atomNetWMStateFullscreen]:
227+ on := apply(rec.fullscreen)
228+ if on == rec.fullscreen {
229+ continue
230+ }
231+ rec.fullscreen = on
232+ if rec.ready {
233+ x.host.Fullscreen(s, on)
234+ rec = x.tab.rec(s)
235+ }
236+ }
237+ }
238+}
239+
240+// onReparentNotify keeps the table to root's direct children: a window moved
241+// away is forgotten, one moved into root is treated as newly created.
242+func (x *XWM) onReparentNotify(ev *xturbo.Event) {
243+ e := xproto.ReparentNotifyOf(ev)
244+ xid := uint32(e.Window())
245+ if xid == x.wmWin {
246+ return
247+ }
248+ s := x.tab.lookup(xid)
249+ if uint32(e.Parent()) != x.root {
250+ if s != 0 {
251+ x.forgetWindow(s)
252+ }
253+ return
254+ }
255+ if s != 0 {
256+ return
257+ }
258+ s = x.insert(xid)
259+ if s == 0 {
260+ return
261+ }
262+ rec := x.tab.rec(s)
263+ rec.x, rec.y = int32(e.X()), int32(e.Y())
264+ if e.OverrideRedirect() {
265+ rec.kind = kindUnmanaged
266+ x.stats.UnmanagedSeen++
267+ return
268+ }
269+ if x.host != nil {
270+ x.adopt(s)
271+ }
272+}
273+
274+// tryPair links the window to the wl_surface carrying its serial, once both
275+// are known and the window is ready (the host maps on the surface's first
276+// buffer, so it must know the window's info by then). Unmanaged windows keep
277+// the surface but are never associated.
278+func (x *XWM) tryPair(s Slot) {
279+ rec := x.tab.rec(s)
280+ if rec.serial == 0 || rec.surface != 0 {
281+ return
282+ }
283+ if rec.kind == kindManaged && !rec.ready {
284+ return
285+ }
286+ for i := 0; i < x.unpairedN; i++ {
287+ if x.unpaired[i].serial != rec.serial {
288+ continue
289+ }
290+ rec.surface = x.unpaired[i].surface
291+ x.unpairedN--
292+ x.unpaired[i] = x.unpaired[x.unpairedN]
293+ if rec.kind == kindManaged {
294+ x.log.Debug().Uint32("xid", rec.xid).Uint64("surface", rec.surface).Msg("x11 window paired")
295+ x.host.Associate(s, rec.surface)
296+ } else {
297+ x.overrideMapped(s, rec)
298+ }
299+ return
300+ }
301+}
302+
303+// SetSerial records the serial of a wl_surface (xwayland_surface_v1.set_serial)
304+// and pairs it with the window announcing the same serial.
305+func (x *XWM) SetSerial(surfaceID uint64, serial uint64) {
306+ if serial == 0 || x.closed {
307+ return
308+ }
309+ for i := 0; i < x.unpairedN; i++ {
310+ if x.unpaired[i].serial == serial {
311+ x.unpaired[i].surface = surfaceID
312+ x.pairSerial(serial)
313+ return
314+ }
315+ }
316+ if x.unpairedN == unpairedCap {
317+ copy(x.unpaired[:], x.unpaired[1:])
318+ x.unpairedN--
319+ if !x.warnedUnpaired {
320+ x.warnedUnpaired = true
321+ x.log.Warn().Msg("too many unpaired surfaces, dropping the oldest")
322+ }
323+ }
324+ x.unpaired[x.unpairedN] = struct{ serial, surface uint64 }{serial, surfaceID}
325+ x.unpairedN++
326+ x.pairSerial(serial)
327+}
328+
329+// pairSerial offers a newly known surface serial to the windows.
330+func (x *XWM) pairSerial(serial uint64) {
331+ for i := range x.tab.recs {
332+ if r := &x.tab.recs[i]; r.kind != kindFree && r.serial == serial && r.surface == 0 {
333+ x.tryPair(Slot(i + 1))
334+ break
335+ }
336+ }
337+ x.wake()
338+}
339+
340+// SurfaceGone forgets a destroyed wl_surface.
341+func (x *XWM) SurfaceGone(surfaceID uint64) {
342+ for i := 0; i < x.unpairedN; i++ {
343+ if x.unpaired[i].surface == surfaceID {
344+ x.unpairedN--
345+ x.unpaired[i] = x.unpaired[x.unpairedN]
346+ break
347+ }
348+ }
349+ for i := range x.tab.recs {
350+ if r := &x.tab.recs[i]; r.kind != kindFree && r.surface == surfaceID {
351+ r.surface = 0
352+ if r.kind == kindUnmanaged && r.ovPaired {
353+ r.ovPaired = false
354+ x.host.OverrideGone(Slot(i + 1))
355+ }
356+ }
357+ }
358+ x.wake()
359+}
A internal/adapters/wayland/xwm/mapflow_test.go +806 -0
@@ -0,0 +1,806 @@
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"), hs.p.atom("_NET_WM_BYPASS_COMPOSITOR"),
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+ {"bypass 1", func(hs *harness, w uint32) {
184+ hs.words(w, "_NET_WM_BYPASS_COMPOSITOR", uint32(xproto.AtomCardinal), 1)
185+ }, func(i WindowInfo) { require.EqualValues(t, 1, i.Bypass) }},
186+ {"bypass 2 kept", func(hs *harness, w uint32) {
187+ hs.words(w, "_NET_WM_BYPASS_COMPOSITOR", uint32(xproto.AtomCardinal), 2)
188+ }, func(i WindowInfo) { require.EqualValues(t, 2, i.Bypass) }},
189+ {"bypass absent", func(hs *harness, w uint32) {}, func(i WindowInfo) { require.Zero(t, i.Bypass) }},
190+ {"class steam_app", func(hs *harness, w uint32) {
191+ hs.p.setProp(w, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("steam_app_892970\x00steam_app_892970\x00"))
192+ }, func(i WindowInfo) { require.Equal(t, "steam_app_892970", i.AppID) }},
193+ {"fullscreen at map", func(hs *harness, w uint32) {
194+ hs.atoms(w, "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN")
195+ }, func(i WindowInfo) { require.True(t, i.Fullscreen) }},
196+ }
197+ for _, c := range cases {
198+ t.Run(c.name, func(t *testing.T) {
199+ hs := newHarness(t, nil)
200+ const win = 0x500001
201+ hs.classic(win)
202+ c.setup(hs, win)
203+ hs.mapAndWait(win)
204+ c.want(hs.ready[win])
205+ })
206+ }
207+}
208+
209+func TestMapFlowTransientForManagedWindowFloats(t *testing.T) {
210+ hs := newHarness(t, nil)
211+ const parent, child = 0x500001, 0x500002
212+ hs.classic(parent)
213+ ps := hs.mapAndWait(parent)
214+ hs.classic(child)
215+ hs.p.setProp(child, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(parent))
216+ hs.mapAndWait(child)
217+ require.True(t, hs.ready[child].Floating)
218+ require.Equal(t, ps, hs.ready[child].Parent)
219+}
220+
221+func TestMapFlowTransientForUnknownWindowIsNotAParent(t *testing.T) {
222+ hs := newHarness(t, nil)
223+ const win = 0x500001
224+ hs.classic(win)
225+ hs.p.setProp(win, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(0x777777))
226+ hs.mapAndWait(win)
227+ require.Zero(t, hs.ready[win].Parent)
228+ require.False(t, hs.ready[win].Floating)
229+}
230+
231+func TestMapFlowClassFallsBackToInstance(t *testing.T) {
232+ hs := newHarness(t, nil)
233+ const win = 0x500001
234+ hs.classic(win)
235+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("onlyinstance\x00"))
236+ hs.mapAndWait(win)
237+ require.Equal(t, "onlyinstance", hs.ready[win].AppID)
238+}
239+
240+func TestMapFlowTitleFallsBackToWMName(t *testing.T) {
241+ hs := newHarness(t, nil)
242+ const win = 0x500001
243+ hs.classic(win)
244+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_NAME"]) })
245+ hs.mapAndWait(win)
246+ require.Equal(t, "old title", hs.ready[win].Title)
247+}
248+
249+func TestMapFlowPIDFromXRes(t *testing.T) {
250+ hs := newHarness(t, func(p *xpeer) { p.pids = map[uint32]uint32{0x500001: 777} })
251+ const win = 0x500001
252+ hs.classic(win)
253+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
254+ hs.create(win)
255+ hs.expectReady(win)
256+ hs.p.mapRequest(win)
257+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
258+ require.Equal(t, 777, hs.ready[win].PID, "Ready waited for the QueryClientIds reply")
259+ require.Equal(t, 1, hs.count(func(r xpeerReq) bool { return r.major == 144 && r.minor == 4 }))
260+ require.Zero(t, hs.x.tab.rec(hs.x.tab.lookup(win)).pending)
261+}
262+
263+func TestMapFlowPIDUnknownStillReady(t *testing.T) {
264+ hs := newHarness(t, nil)
265+ const win = 0x500001
266+ hs.classic(win)
267+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
268+ hs.mapAndWait(win)
269+ require.Zero(t, hs.ready[win].PID)
270+}
271+
272+func TestMapFlowNoXResAsksNothing(t *testing.T) {
273+ exts := fullPeerExts()
274+ delete(exts, "X-Resource")
275+ fd, p := startXPeer(t, exts)
276+ x, err := Boot(context.Background(), fd, testLog())
277+ require.NoError(t, err)
278+ t.Cleanup(x.CloseConn)
279+ h := newMockhost(t)
280+ src := newMockfdSource(t)
281+ src.EXPECT().Remove().Maybe()
282+ post, _ := newPost()
283+ x.attach(src, h, post)
284+ hs := &harness{t: t, x: x, p: p, h: h, ready: map[uint32]WindowInfo{}}
285+ const win = 0x500001
286+ hs.classic(win)
287+ p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_PID"]) })
288+ hs.mapAndWait(win)
289+ require.Zero(t, hs.count(func(r xpeerReq) bool { return r.major == 144 }))
290+}
291+
292+func TestMapFlowBadWindowOnPropertiesNeverReady(t *testing.T) {
293+ hs := newHarness(t, nil)
294+ const win = 0x500001
295+ hs.classic(win)
296+ hs.p.set(func(p *xpeer) { p.bad[win] = true })
297+ s := hs.create(win)
298+ hs.p.mapRequest(win)
299+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Errors >= uint64(propCount)+1 })
300+ require.Zero(t, hs.x.tab.rec(s).pending, "failed replies are no longer awaited")
301+ require.False(t, hs.x.tab.rec(s).ready)
302+ require.Zero(t, hs.x.Stats().Unattributed)
303+
304+ hs.h.EXPECT().Dissociate(s, true).Once()
305+ hs.p.destroyNotify(win)
306+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
307+}
308+
309+func TestMapRequestTwiceBeforeReadyAsksOnce(t *testing.T) {
310+ hs := newHarness(t, nil)
311+ const win = 0x500001
312+ hs.classic(win)
313+ hs.create(win)
314+ hs.p.holdReplies()
315+ hs.p.mapRequest(win)
316+ hs.p.mapRequest(win)
317+ hs.expectReady(win)
318+ hs.waitMap(win)
319+ hs.p.release()
320+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
321+ require.Equal(t, 1, hs.count(func(r xpeerReq) bool { return r.major == opMapWindow && r.u32(4) == win }))
322+}
323+
324+func TestMapNotifyMarksMapped(t *testing.T) {
325+ hs := newHarness(t, nil)
326+ const win = 0x500001
327+ hs.classic(win)
328+ s := hs.mapAndWait(win)
329+ hs.p.mapNotify(win)
330+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).mapped })
331+}
332+
333+// Property changes after Ready.
334+
335+func (hs *harness) notify(win uint32, atom string, deleted bool) {
336+ hs.t.Helper()
337+ before := hs.x.Stats().Replies
338+ hs.p.propertyNotify(win, hs.p.atom(atom), deleted)
339+ if deleted {
340+ hs.p.propertyNotify(win, hs.p.atom("WM_PROTOCOLS"), false) // sentinel: one reply follows
341+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
342+ return
343+ }
344+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before })
345+}
346+
347+func TestPropertyChangedTitleReachesHostOnce(t *testing.T) {
348+ hs := newHarness(t, nil)
349+ const win = 0x500001
350+ hs.classic(win)
351+ s := hs.mapAndWait(win)
352+
353+ hs.h.EXPECT().Title(s, "renamed").Once()
354+ hs.str(win, "_NET_WM_NAME", hs.p.atom("UTF8_STRING"), "renamed")
355+ hs.notify(win, "_NET_WM_NAME", false)
356+ require.Equal(t, "renamed", hs.x.tab.rec(s).title)
357+
358+ // Same value again: no host call (the mock fails on an unexpected one).
359+ hs.notify(win, "_NET_WM_NAME", false)
360+
361+ // WM_NAME is ignored while _NET_WM_NAME is set.
362+ hs.p.setProp(win, uint32(xproto.AtomWmName), uint32(xproto.AtomString), 8, []byte("legacy"))
363+ before := hs.x.Stats().Events
364+ hs.p.propertyNotify(win, uint32(xproto.AtomWmName), false)
365+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events > before })
366+ require.Equal(t, "renamed", hs.x.tab.rec(s).title)
367+}
368+
369+func TestPropertyNetNameDeletedFallsBackToWMName(t *testing.T) {
370+ hs := newHarness(t, nil)
371+ const win = 0x500001
372+ hs.classic(win)
373+ s := hs.mapAndWait(win)
374+ hs.p.set(func(p *xpeer) { delete(p.props[win], p.atoms["_NET_WM_NAME"]) })
375+ hs.h.EXPECT().Title(s, "old title").Once()
376+ hs.p.propertyNotify(win, hs.p.atom("_NET_WM_NAME"), true)
377+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).title == "old title" })
378+}
379+
380+func TestPropertyClassChangeReachesHost(t *testing.T) {
381+ hs := newHarness(t, nil)
382+ const win = 0x500001
383+ hs.classic(win)
384+ s := hs.mapAndWait(win)
385+ hs.h.EXPECT().AppID(s, "Other").Once()
386+ hs.p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("i\x00Other\x00"))
387+ hs.p.propertyNotify(win, uint32(xproto.AtomWmClass), false)
388+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).class == "Other" })
389+}
390+
391+func TestPropertyTransientChangeReachesHost(t *testing.T) {
392+ hs := newHarness(t, nil)
393+ const a, b = 0x500001, 0x500002
394+ hs.classic(a)
395+ as := hs.mapAndWait(a)
396+ hs.classic(b)
397+ bs := hs.mapAndWait(b)
398+ hs.h.EXPECT().Parent(bs, as).Once()
399+ hs.p.setProp(b, uint32(xproto.AtomWmTransientFor), uint32(xproto.AtomWindow), 32, le32(a))
400+ hs.p.propertyNotify(b, uint32(xproto.AtomWmTransientFor), false)
401+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(bs).transient == a })
402+}
403+
404+func TestPropertyInputProtocolsAndHints(t *testing.T) {
405+ hs := newHarness(t, nil)
406+ const win = 0x500001
407+ hs.classic(win)
408+ hs.atoms(win, "WM_PROTOCOLS", "WM_DELETE_WINDOW", "WM_TAKE_FOCUS")
409+ hs.p.setProp(win, uint32(xproto.AtomWmHints), uint32(xproto.AtomWmHints), 32, le32(hintInput, 0, 0, 0, 0, 0, 0, 0, 0))
410+ s := hs.mapAndWait(win)
411+ r := hs.x.tab.rec(s)
412+ require.True(t, r.deleteWindow)
413+ require.True(t, r.takeFocus)
414+ require.False(t, r.input, "WM_HINTS input = false")
415+
416+ // No WM_HINTS: input defaults to true.
417+ const other = 0x500002
418+ hs.classic(other)
419+ o := hs.mapAndWait(other)
420+ require.True(t, hs.x.tab.rec(o).input)
421+ require.False(t, hs.x.tab.rec(o).deleteWindow)
422+}
423+
424+func TestPropertyUnwatchedAtomAsksNothing(t *testing.T) {
425+ hs := newHarness(t, nil)
426+ const win = 0x500001
427+ hs.classic(win)
428+ hs.mapAndWait(win)
429+ n := len(hs.p.requests())
430+ hs.p.propertyNotify(win, hs.p.atom("_MOTIF_WM_HINTS"), false)
431+ hs.p.propertyNotify(0x999999, hs.p.atom("_NET_WM_NAME"), false) // unknown window
432+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
433+ require.Len(t, hs.p.requests(), n)
434+}
435+
436+// Pairing.
437+
438+func (hs *harness) serialMsg(win uint32, serial uint64) {
439+ hs.p.clientMessage(win, hs.p.atom("WL_SURFACE_SERIAL"), uint32(serial), uint32(serial>>32))
440+ hs.t.Helper()
441+ before := hs.x.Stats().Events
442+ readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Events > before })
443+}
444+
445+func TestPairingSerialBeforeSetSerial(t *testing.T) {
446+ hs := newHarness(t, nil)
447+ const win = 0x500001
448+ hs.classic(win)
449+ s := hs.mapAndWait(win)
450+ hs.serialMsg(win, 0x1_0000_0002)
451+ require.EqualValues(t, 0x1_0000_0002, hs.x.tab.rec(s).serial, "high word in data32[1]")
452+ hs.h.EXPECT().Associate(s, uint64(99)).Once()
453+ hs.x.SetSerial(99, 0x1_0000_0002)
454+ require.EqualValues(t, 99, hs.x.tab.rec(s).surface)
455+ require.Zero(t, hs.x.unpairedN)
456+}
457+
458+func TestPairingSetSerialBeforeSerial(t *testing.T) {
459+ hs := newHarness(t, nil)
460+ const win = 0x500001
461+ hs.classic(win)
462+ s := hs.mapAndWait(win)
463+ hs.x.SetSerial(99, 7)
464+ require.Equal(t, 1, hs.x.unpairedN)
465+ hs.h.EXPECT().Associate(s, uint64(99)).Once()
466+ hs.serialMsg(win, 7)
467+ require.Zero(t, hs.x.unpairedN)
468+}
469+
470+func TestPairingWaitsForReady(t *testing.T) {
471+ hs := newHarness(t, nil)
472+ const win = 0x500001
473+ hs.classic(win)
474+ s := hs.create(win)
475+ hs.x.SetSerial(5, 3)
476+ hs.serialMsg(win, 3) // not mapped yet: no Associate (the mock fails on one)
477+ require.Zero(t, hs.x.tab.rec(s).surface)
478+
479+ hs.h.EXPECT().Associate(s, uint64(5)).Once()
480+ hs.expectReady(win)
481+ hs.p.mapRequest(win)
482+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).surface != 0 })
483+}
484+
485+func TestPairingSerialForUnknownWindowIgnored(t *testing.T) {
486+ hs := newHarness(t, nil)
487+ hs.x.SetSerial(5, 3)
488+ hs.serialMsg(0x888888, 3)
489+ require.Equal(t, 1, hs.x.unpairedN, "stays unpaired")
490+}
491+
492+func TestPairingSurfaceGoneBeforePair(t *testing.T) {
493+ hs := newHarness(t, nil)
494+ const win = 0x500001
495+ hs.classic(win)
496+ s := hs.mapAndWait(win)
497+ hs.x.SetSerial(5, 3)
498+ hs.x.SurfaceGone(5)
499+ require.Zero(t, hs.x.unpairedN)
500+ hs.serialMsg(win, 3) // the surface is gone: nothing to pair with
501+ require.Zero(t, hs.x.tab.rec(s).surface)
502+}
503+
504+func TestPairingSurfaceGoneAfterPairClearsLink(t *testing.T) {
505+ hs := newHarness(t, nil)
506+ const win = 0x500001
507+ hs.classic(win)
508+ s := hs.mapAndWait(win)
509+ hs.serialMsg(win, 3)
510+ hs.h.EXPECT().Associate(s, uint64(5)).Once()
511+ hs.x.SetSerial(5, 3)
512+ hs.x.SurfaceGone(5)
513+ require.Zero(t, hs.x.tab.rec(s).surface)
514+}
515+
516+func TestPairingDuplicateSerialIgnored(t *testing.T) {
517+ hs := newHarness(t, nil)
518+ const win = 0x500001
519+ hs.classic(win)
520+ s := hs.mapAndWait(win)
521+ hs.serialMsg(win, 3)
522+ hs.serialMsg(win, 4)
523+ require.EqualValues(t, 3, hs.x.tab.rec(s).serial)
524+ hs.x.SetSerial(8, 4) // nobody announces 4: stays unpaired
525+ require.Equal(t, 1, hs.x.unpairedN)
526+}
527+
528+func TestPairingUnmanagedKeepsSurfaceWithoutAssociate(t *testing.T) {
529+ hs := newHarness(t, nil)
530+ hs.p.createNotify(0x500009, true)
531+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
532+ hs.serialMsg(0x500009, 3)
533+ hs.x.SetSerial(5, 3) // the mock fails on an Associate
534+ require.EqualValues(t, 5, hs.x.tab.rec(hs.x.tab.lookup(0x500009)).surface)
535+}
536+
537+func TestPairingTableOverflowDropsOldest(t *testing.T) {
538+ hs := newHarness(t, nil)
539+ for i := uint64(1); i <= unpairedCap+1; i++ {
540+ hs.x.SetSerial(100+i, i)
541+ }
542+ require.Equal(t, unpairedCap, hs.x.unpairedN)
543+ for i := 0; i < hs.x.unpairedN; i++ {
544+ require.NotEqual(t, uint64(1), hs.x.unpaired[i].serial, "oldest dropped")
545+ }
546+}
547+
548+func TestPairingZeroSerialIgnored(t *testing.T) {
549+ hs := newHarness(t, nil)
550+ hs.x.SetSerial(5, 0)
551+ require.Zero(t, hs.x.unpairedN)
552+}
553+
554+func TestLegacySurfaceIDCountedAndIgnored(t *testing.T) {
555+ hs := newHarness(t, nil)
556+ const win = 0x500001
557+ hs.classic(win)
558+ s := hs.mapAndWait(win)
559+ hs.p.clientMessage(win, hs.p.atom("WL_SURFACE_ID"), 12)
560+ readUntil(t, hs.x, func() bool { return hs.x.Stats().LegacyID == 1 })
561+ require.Zero(t, hs.x.tab.rec(s).serial)
562+}
563+
564+func TestNetWMStateClientMessageForwardsFullscreen(t *testing.T) {
565+ hs := newHarness(t, nil)
566+ const win = 0x500001
567+ hs.classic(win)
568+ s := hs.mapAndWait(win)
569+ hs.h.EXPECT().Fullscreen(s, true).Once()
570+ hs.p.clientMessage(win, hs.p.atom("_NET_WM_STATE"), 1, hs.p.atom("_NET_WM_STATE_FULLSCREEN"))
571+ readUntil(t, hs.x, func() bool { return hs.x.tab.rec(s).fullscreen })
572+ hs.h.EXPECT().Fullscreen(s, false).Once() // toggle
573+ hs.p.clientMessage(win, hs.p.atom("_NET_WM_STATE"), 2, hs.p.atom("_NET_WM_STATE_FULLSCREEN"))
574+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).fullscreen })
575+ // Not written back by the message itself.
576+ st, ok := hs.p.prop(win, "_NET_WM_STATE")
577+ require.True(t, ok)
578+ require.Empty(t, st.data)
579+}
580+
581+// Unmap, destroy, remap.
582+
583+func TestUnmapDissociatesAndRemapRereadsProperties(t *testing.T) {
584+ hs := newHarness(t, nil)
585+ const win = 0x500001
586+ hs.classic(win)
587+ s := hs.mapAndWait(win)
588+
589+ // A synthetic UnmapNotify (ICCCM withdraw request) is not an unmap.
590+ hs.p.unmapNotify(win, true)
591+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
592+ require.True(t, hs.x.tab.rec(s).ready)
593+
594+ hs.h.EXPECT().Dissociate(s, false).Once()
595+ hs.p.unmapNotify(win, false)
596+ readUntil(t, hs.x, func() bool { return !hs.x.tab.rec(s).ready })
597+ r := hs.x.tab.rec(s)
598+ require.False(t, r.mapping)
599+ require.Zero(t, r.serial)
600+ hs.p.waitReq(0, func(q xpeerReq) bool {
601+ return q.major == opSetInputFocus || q.major == opChangeProperty && q.u32(4) == win
602+ })
603+ readUntil(t, hs.x, func() bool {
604+ v, ok := hs.p.prop(win, "WM_STATE")
605+ return ok && len(v.data) == 8 && binary.LittleEndian.Uint32(v.data) == 0
606+ })
607+ _, ok := hs.p.prop(win, "_NET_WM_STATE")
608+ require.False(t, ok, "_NET_WM_STATE deleted")
609+ list, _ := hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
610+ require.Empty(t, list.data)
611+
612+ // Remap starts over.
613+ delete(hs.ready, win)
614+ hs.expectReady(win)
615+ n := hs.count(func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(4) == win })
616+ hs.p.mapRequest(win)
617+ readUntil(t, hs.x, func() bool { _, ok := hs.ready[win]; return ok })
618+ require.Equal(t, n+propCount, hs.count(func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(4) == win }))
619+ list, _ = hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
620+ require.Equal(t, []uint32{win}, atomsOf(list))
621+}
622+
623+func TestDestroyWhileMappingNeverReady(t *testing.T) {
624+ hs := newHarness(t, nil)
625+ const win = 0x500001
626+ hs.classic(win)
627+ s := hs.create(win)
628+ hs.p.holdReplies()
629+ hs.p.mapRequest(win)
630+ hs.waitMap(win)
631+ hs.h.EXPECT().Dissociate(s, true).Once()
632+ hs.p.destroyNotify(win)
633+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
634+ replies := hs.x.Stats().Replies
635+ hs.p.release() // replies for a window that is gone: xturbo retired their slots
636+ readUntil(t, hs.x, func() bool { return hs.x.conn.Stats().DroppedReplies >= uint64(propCount) })
637+ require.Equal(t, replies, hs.x.Stats().Replies, "none reaches the XWM")
638+}
639+
640+func TestStaleRepliesAfterSlotReuseAreIgnored(t *testing.T) {
641+ hs := newHarness(t, nil)
642+ const old, fresh = 0x500001, 0x500002
643+ hs.classic(old)
644+ hs.classic(fresh)
645+ s := hs.create(old)
646+ hs.p.holdReplies()
647+ hs.p.mapRequest(old)
648+ hs.waitMap(old)
649+ hs.h.EXPECT().Dissociate(s, true).Once()
650+ hs.p.destroyNotify(old)
651+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(old) == 0 })
652+
653+ hs.expectCreated(fresh)
654+ hs.p.createNotify(fresh, false)
655+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(fresh) != 0 })
656+ require.Equal(t, s, hs.x.tab.lookup(fresh), "the freed slot is reused")
657+
658+ hs.p.release()
659+ readUntil(t, hs.x, func() bool { return hs.x.conn.Stats().DroppedReplies >= uint64(propCount) })
660+
661+ // A reply that does get through with the old tag (slot reused, generation
662+ // bumped) is ignored.
663+ oldTag := s | Slot(hs.x.tab.rec(s).gen-1)<<16
664+ rep := make([]byte, 32)
665+ rep[0] = 1
666+ hs.x.onReply(&xturbo.Reply{Kind: kindProp, Args: [4]uint32{uint32(oldTag), propClass}, Raw: rep})
667+ hs.x.onReply(&xturbo.Reply{Kind: kindGeom, Args: [4]uint32{uint32(oldTag)}, Raw: rep})
668+ require.EqualValues(t, 2, hs.x.Stats().Stale)
669+ require.False(t, hs.x.tab.rec(s).ready, "stale replies must not complete the new window")
670+ require.Zero(t, hs.x.tab.rec(s).pending)
671+}
672+
673+// Reparenting.
674+
675+func TestReparentAwayFromRootRemovesManagedWindow(t *testing.T) {
676+ hs := newHarness(t, nil)
677+ const win = 0x500001
678+ hs.classic(win)
679+ s := hs.mapAndWait(win)
680+ hs.h.EXPECT().Dissociate(s, true).Once()
681+ hs.p.reparentNotify(win, 0x600000)
682+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) == 0 })
683+ list, _ := hs.p.prop(peerRoot, "_NET_CLIENT_LIST")
684+ require.Empty(t, list.data)
685+ require.Zero(t, hs.x.tab.n)
686+}
687+
688+func TestReparentAwayOfUnmanagedWindowRemovesIt(t *testing.T) {
689+ hs := newHarness(t, nil)
690+ hs.p.createNotify(0x500009, true)
691+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
692+ hs.p.reparentNotify(0x500009, 0x600000)
693+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) == 0 })
694+}
695+
696+func TestReparentIntoRootAdoptsWindow(t *testing.T) {
697+ hs := newHarness(t, nil)
698+ const win = 0x500001
699+ hs.expectCreated(win)
700+ hs.p.reparentNotify(win, peerRoot)
701+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(win) != 0 })
702+ require.Equal(t, kindManaged, hs.x.tab.rec(hs.x.tab.lookup(win)).kind)
703+
704+ // Already known: no second Created.
705+ hs.p.reparentNotify(win, peerRoot)
706+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 2 })
707+}
708+
709+func TestReparentIntoRootOfOverrideRedirectWindowStaysUnmanaged(t *testing.T) {
710+ hs := newHarness(t, nil)
711+ hs.p.event(21, func(b *[32]byte) {
712+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
713+ binary.LittleEndian.PutUint32(b[8:], 0x500009)
714+ binary.LittleEndian.PutUint32(b[12:], peerRoot)
715+ b[20] = 1
716+ })
717+ readUntil(t, hs.x, func() bool { return hs.x.tab.lookup(0x500009) != 0 })
718+ require.Equal(t, kindUnmanaged, hs.x.tab.rec(hs.x.tab.lookup(0x500009)).kind)
719+}
720+
721+func TestReparentOfWMWindowIgnored(t *testing.T) {
722+ hs := newHarness(t, nil)
723+ hs.p.reparentNotify(hs.x.wmWin, peerRoot)
724+ readUntil(t, hs.x, func() bool { return hs.x.Stats().Events >= 1 })
725+ require.Zero(t, hs.x.tab.lookup(hs.x.wmWin))
726+}
727+
728+// Window known only before Attach: Attach replays the map flow.
729+func TestAttachMapsWindowsRequestedBeforeAttach(t *testing.T) {
730+ x, p := bootPeer(t, nil)
731+ const win = 0x500001
732+ p.setProp(win, uint32(xproto.AtomWmClass), uint32(xproto.AtomString), 8, []byte("a\x00B\x00"))
733+ p.createNotify(win, false)
734+ p.mapRequest(win)
735+ readUntil(t, x, func() bool { return x.Stats().Events >= 2 })
736+ h := newMockhost(t)
737+ src := newMockfdSource(t)
738+ src.EXPECT().Remove().Maybe()
739+ post, _ := newPost()
740+ var got WindowInfo
741+ done := false
742+ h.EXPECT().Created(mock.Anything, uint32(win)).Return(ports.WindowID(1)).Once()
743+ h.EXPECT().Ready(mock.Anything, mock.Anything).Run(func(_ Slot, i WindowInfo) { got, done = i, true }).Once()
744+ x.attach(src, h, post)
745+ readUntil(t, x, func() bool { return done })
746+ require.Equal(t, "B", got.AppID)
747+}
748+
749+// Replies complete a map in any order (a server may answer the geometry
750+// before the properties it was asked for first): Ready fires on the last one.
751+func TestMapFlowReadyFiresOnLastReplyInAnyOrder(t *testing.T) {
752+ hs := newHarness(t, nil)
753+ const win = 0x500001
754+ hs.classic(win)
755+ s := hs.create(win)
756+ r := hs.x.tab.rec(s)
757+ r.mapping, r.pending = true, propMaskAll&^propClientIDs
758+ hs.expectReady(win)
759+ order := []int{propCount, propPID, propBypass, propClass, propNetName, propName, propHints, propNormalHints, propProtocols, propTransientFor, propWindowType, propState}
760+ for i, bit := range order {
761+ require.False(t, hs.x.tab.rec(s).ready, i)
762+ if bit == propCount { // the geometry
763+ hs.x.tab.rec(s).w, hs.x.tab.rec(s).h = 300, 200
764+ hs.x.tab.rec(s).pending &^= propGeom
765+ hs.x.checkReady(s)
766+ continue
767+ }
768+ var val []byte
769+ var format uint8 = 8
770+ switch bit {
771+ case propClass:
772+ val = []byte("a\x00B\x00")
773+ case propNetName:
774+ val = []byte("t")
775+ case propPID:
776+ val, format = le32(5), 32
777+ default:
778+ format = 32
779+ }
780+ x := hs.x
781+ x.applyProp(s, bit, format, val)
782+ x.tab.rec(s).pending &^= 1 << bit
783+ x.checkReady(s)
784+ }
785+ require.True(t, hs.x.tab.rec(s).ready)
786+ require.Equal(t, "B", hs.ready[win].AppID)
787+ require.Equal(t, "t", hs.ready[win].Title)
788+ require.Equal(t, 5, hs.ready[win].PID)
789+}
790+
791+func TestMapFlowWholeOutputRectSetsHint(t *testing.T) {
792+ hs := newHarness(t, nil)
793+ hs.x.space.Set(ports.Layout{out(0, 0, 1920, 1080), out(1920, 0, 1280, 1024)})
794+ const win = 0x500001
795+ hs.classic(win)
796+ s := hs.create(win)
797+ r := hs.x.tab.rec(s)
798+ r.x, r.y, r.w, r.h, r.sized = 1920, 0, 1280, 1024, true
799+ info := hs.x.info(s)
800+ require.True(t, info.HasAt)
801+ require.Equal(t, 1920, info.AtX)
802+ r.h = 1000
803+ require.False(t, hs.x.info(s).HasAt)
804+ r.h, r.sized = 1024, false
805+ require.False(t, hs.x.info(s).HasAt)
806+}
A internal/adapters/wayland/xwm/props.go +376 -0
@@ -0,0 +1,376 @@
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+ propBypass
28+ propCount
29+)
30+
31+const (
32+ propGeom uint16 = 1 << propCount
33+ propClientIDs uint16 = propGeom << 1
34+ // propMaskAll is what a map waits for: every property and the geometry.
35+ propMaskAll uint16 = propGeom<<1 - 1
36+)
37+
38+// WM_NORMAL_HINTS flags and WM_HINTS flags.
39+const (
40+ hintInput = 1 << 0
41+ hintMinSize = 1 << 4
42+ hintMaxSize = 1 << 5
43+)
44+
45+// maxPropWords bounds one GetProperty (4 KiB), so titles are truncated there.
46+const maxPropWords = 1024
47+
48+// propIndex maps a property atom to its index, or -1 when it is not watched.
49+func (x *XWM) propIndex(a uint32) int {
50+ switch a {
51+ case uint32(xproto.AtomWmClass):
52+ return propClass
53+ case uint32(xproto.AtomWmName):
54+ return propName
55+ case uint32(xproto.AtomWmHints):
56+ return propHints
57+ case uint32(xproto.AtomWmNormalHints):
58+ return propNormalHints
59+ case uint32(xproto.AtomWmTransientFor):
60+ return propTransientFor
61+ case x.atoms[atomNetWMName]:
62+ return propNetName
63+ case x.atoms[atomWMProtocols]:
64+ return propProtocols
65+ case x.atoms[atomNetWMWindowType]:
66+ return propWindowType
67+ case x.atoms[atomNetWMState]:
68+ return propState
69+ case x.atoms[atomNetWMPID]:
70+ return propPID
71+ case x.atoms[atomNetWMBypassCompositor]:
72+ return propBypass
73+ }
74+ return -1
75+}
76+
77+func (x *XWM) propAtom(idx int) xproto.Atom {
78+ switch idx {
79+ case propClass:
80+ return xproto.AtomWmClass
81+ case propName:
82+ return xproto.AtomWmName
83+ case propHints:
84+ return xproto.AtomWmHints
85+ case propNormalHints:
86+ return xproto.AtomWmNormalHints
87+ case propTransientFor:
88+ return xproto.AtomWmTransientFor
89+ case propNetName:
90+ return xproto.Atom(x.atoms[atomNetWMName])
91+ case propProtocols:
92+ return xproto.Atom(x.atoms[atomWMProtocols])
93+ case propWindowType:
94+ return xproto.Atom(x.atoms[atomNetWMWindowType])
95+ case propState:
96+ return xproto.Atom(x.atoms[atomNetWMState])
97+ case propBypass:
98+ return xproto.Atom(x.atoms[atomNetWMBypassCompositor])
99+ default:
100+ return xproto.Atom(x.atoms[atomNetWMPID])
101+ }
102+}
103+
104+// requestProp queues a GetProperty for property idx of s.
105+func (x *XWM) requestProp(s Slot, idx int) {
106+ rec := x.tab.rec(s)
107+ seq := xproto.GetProperty(x.conn.Buf(), false, xproto.Window(rec.xid), x.propAtom(idx), xproto.AtomAny, 0, maxPropWords)
108+ rec.pending |= 1 << idx
109+ x.expect(seq, kindProp, x.tab.tag(s), uint32(idx))
110+}
111+
112+// onPropertyNotify handles a changed property of a managed window that is
113+// being mapped or is mapped; unwatched atoms cost nothing.
114+func (x *XWM) onPropertyNotify(ev *xturbo.Event) {
115+ e := xproto.PropertyNotifyOf(ev)
116+ if uint32(e.Window()) == x.selWin && x.selWin != 0 {
117+ x.onSelProperty(ev)
118+ return
119+ }
120+ if x.onRequestorProperty(e) {
121+ return
122+ }
123+ s := x.tab.lookup(uint32(e.Window()))
124+ if s == 0 {
125+ return
126+ }
127+ if rec := x.tab.rec(s); rec.kind != kindManaged || !rec.mapping {
128+ return
129+ } else if rec.ready && x.propIndex(uint32(e.Atom())) == propState {
130+ // The XWM owns _NET_WM_STATE of a mapped window: our own writes
131+ // would feed back, and clients change it with ClientMessages.
132+ return
133+ }
134+ idx := x.propIndex(uint32(e.Atom()))
135+ if idx < 0 {
136+ return
137+ }
138+ if e.State() == xproto.PropertyDelete {
139+ x.applyProp(s, idx, 0, nil)
140+ return
141+ }
142+ x.requestProp(s, idx)
143+}
144+
145+// onPropReply decodes one GetProperty reply into the record. An absent
146+// property (format 0) clears its fields.
147+func (x *XWM) onPropReply(s Slot, _ *record, r *xturbo.Reply) {
148+ idx := int(r.Args[1])
149+ if idx < 0 || idx >= propCount {
150+ return
151+ }
152+ var format uint8
153+ var val []byte
154+ if rep, ok := xproto.ParseGetPropertyReply(r.Raw); ok {
155+ format, val = rep.Format(), rep.Value()
156+ }
157+ x.applyProp(s, idx, format, val)
158+ x.tab.rec(s).pending &^= 1 << idx
159+ x.checkReady(s)
160+}
161+
162+func word(b []byte, i int) uint32 { return binary.LittleEndian.Uint32(b[4*i:]) }
163+
164+// applyProp stores one decoded property. It does not allocate unless a string
165+// changed. Host calls come last: the record is not touched after them.
166+func (x *XWM) applyProp(s Slot, idx int, format uint8, val []byte) {
167+ rec := x.tab.rec(s)
168+ if idx == propClass || idx == propName || idx == propNetName {
169+ if format != 8 {
170+ val = nil
171+ }
172+ } else if format != 32 {
173+ val = nil
174+ }
175+ switch idx {
176+ case propClass:
177+ inst, class := val, []byte(nil)
178+ if i := bytes.IndexByte(val, 0); i >= 0 {
179+ inst, class = val[:i], val[i+1:]
180+ if j := bytes.IndexByte(class, 0); j >= 0 {
181+ class = class[:j]
182+ }
183+ }
184+ app := class
185+ if len(app) == 0 {
186+ app = inst
187+ }
188+ if rec.class == string(app) {
189+ return
190+ }
191+ cls := strings.ToValidUTF8(string(app), "\uFFFD")
192+ if rec.class == cls {
193+ return
194+ }
195+ rec.class = cls
196+ if rec.ready {
197+ x.host.AppID(s, rec.class)
198+ }
199+ case propName:
200+ if !rec.hasNetName {
201+ x.setTitle(s, val)
202+ }
203+ case propNetName:
204+ had := rec.hasNetName
205+ rec.hasNetName = len(val) > 0
206+ if rec.hasNetName {
207+ x.setTitle(s, val)
208+ } else if had && rec.mapping {
209+ x.requestProp(s, propName)
210+ }
211+ case propHints:
212+ rec.input = true
213+ if len(val) >= 8 && word(val, 0)&hintInput != 0 {
214+ rec.input = word(val, 1) != 0
215+ }
216+ case propNormalHints:
217+ rec.minW, rec.minH, rec.maxW, rec.maxH = 0, 0, 0, 0
218+ if len(val) >= 36 {
219+ fl := word(val, 0)
220+ if fl&hintMinSize != 0 {
221+ rec.minW, rec.minH = int32(word(val, 5)), int32(word(val, 6))
222+ }
223+ if fl&hintMaxSize != 0 {
224+ rec.maxW, rec.maxH = int32(word(val, 7)), int32(word(val, 8))
225+ }
226+ }
227+ x.classify(rec)
228+ case propProtocols:
229+ rec.deleteWindow, rec.takeFocus = false, false
230+ for i := 0; i+4 <= len(val); i += 4 {
231+ switch binary.LittleEndian.Uint32(val[i:]) {
232+ case x.atoms[atomWMDeleteWindow]:
233+ rec.deleteWindow = true
234+ case x.atoms[atomWMTakeFocus]:
235+ rec.takeFocus = true
236+ }
237+ }
238+ case propTransientFor:
239+ var t uint32
240+ if len(val) >= 4 {
241+ t = word(val, 0)
242+ }
243+ if t == rec.transient {
244+ return
245+ }
246+ rec.transient = t
247+ x.classify(rec)
248+ if rec.ready {
249+ x.host.Parent(s, x.managedSlot(t))
250+ }
251+ case propWindowType:
252+ rec.wtype = 0
253+ for i := 0; i+4 <= len(val); i += 4 {
254+ if t := x.windowType(binary.LittleEndian.Uint32(val[i:])); t != 0 {
255+ rec.wtype = t
256+ break
257+ }
258+ }
259+ x.classify(rec)
260+ case propState:
261+ var modal, fs bool
262+ for i := 0; i+4 <= len(val); i += 4 {
263+ switch binary.LittleEndian.Uint32(val[i:]) {
264+ case x.atoms[atomNetWMStateModal]:
265+ modal = true
266+ case x.atoms[atomNetWMStateFullscreen]:
267+ fs = true
268+ }
269+ }
270+ changed := fs != rec.fullscreen
271+ rec.modal, rec.fullscreen = modal, fs
272+ if changed && rec.ready {
273+ x.host.Fullscreen(s, fs)
274+ }
275+ case propBypass:
276+ // Kept for a later VRR/tearing policy; direct scanout and tearing
277+ // do not depend on it. 2 ("do not bypass") is ignored on purpose.
278+ rec.bypass = 0
279+ if len(val) >= 4 {
280+ rec.bypass = uint8(min(word(val, 0), 2))
281+ }
282+ x.log.Debug().Uint32("xid", rec.xid).Uint8("bypass", rec.bypass).Msg("_NET_WM_BYPASS_COMPOSITOR")
283+ case propPID:
284+ if len(val) >= 4 {
285+ rec.pid = int32(word(val, 0))
286+ return
287+ }
288+ rec.pid = 0
289+ if !rec.ready && rec.pending&propClientIDs == 0 && x.conn.Ext().Info(xturbo.ExtRes).Present {
290+ x.queryClientIDs(s)
291+ }
292+ }
293+}
294+
295+// setTitle stores a title when it differs; only a change allocates.
296+func (x *XWM) setTitle(s Slot, val []byte) {
297+ rec := x.tab.rec(s)
298+ if rec.title == string(val) {
299+ return
300+ }
301+ t := strings.ToValidUTF8(string(val), "\uFFFD")
302+ if rec.title == t {
303+ return
304+ }
305+ rec.title = t
306+ if rec.ready {
307+ x.host.Title(s, rec.title)
308+ }
309+}
310+
311+// windowType returns the atom enum of a recognised _NET_WM_WINDOW_TYPE atom,
312+// or 0.
313+func (x *XWM) windowType(a uint32) uint8 {
314+ for t := atomNetWMWindowTypeNormal; t <= atomNetWMWindowTypeDND; t++ {
315+ if x.atoms[t] == a {
316+ return uint8(t)
317+ }
318+ }
319+ return 0
320+}
321+
322+// floatingType reports whether a window type floats by itself.
323+func floatingType(t uint8) bool {
324+ switch int(t) {
325+ case atomNetWMWindowTypeDialog, atomNetWMWindowTypeUtility, atomNetWMWindowTypeSplash, atomNetWMWindowTypeToolbar:
326+ return true
327+ }
328+ return false
329+}
330+
331+// classify sets rec.floating, the single source of truth: a dialog-like
332+// window type, a transient for a managed window, or a fixed size.
333+func (x *XWM) classify(rec *record) {
334+ fixed := rec.minW > 0 && rec.minH > 0 && rec.minW == rec.maxW && rec.minH == rec.maxH
335+ rec.floating = floatingType(rec.wtype) || x.managedSlot(rec.transient) != 0 || fixed
336+}
337+
338+// managedSlot returns the slot of the managed window xid, or 0.
339+func (x *XWM) managedSlot(xid uint32) Slot {
340+ if xid == 0 {
341+ return 0
342+ }
343+ if s := x.tab.lookup(xid); s != 0 && x.tab.rec(s).kind == kindManaged {
344+ return s
345+ }
346+ return 0
347+}
348+
349+// queryClientIDs asks XRes for the PID of the client owning s, which stands
350+// in for a missing _NET_WM_PID: Ready waits for it (propClientIDs).
351+func (x *XWM) queryClientIDs(s Slot) {
352+ rec := x.tab.rec(s)
353+ specs := [1]res.ClientIdSpec{{Client: rec.xid, Mask: res.ClientIdMaskLocalClientPID}}
354+ seq := res.QueryClientIds(x.conn.Buf(), specs[:])
355+ if seq == 0 {
356+ return
357+ }
358+ rec.pending |= propClientIDs
359+ x.expect(seq, kindClientIds, x.tab.tag(s), 0)
360+}
361+
362+// onClientIdsReply stores the PID XRes found and releases the map.
363+func (x *XWM) onClientIdsReply(s Slot, rec *record, r *xturbo.Reply) {
364+ if q, ok := res.ParseQueryClientIdsReply(r.Raw); ok {
365+ it := q.Ids()
366+ for it.Next() {
367+ v := it.Value()
368+ if v.Spec().Mask == res.ClientIdMaskLocalClientPID && v.Value().Len() > 0 {
369+ rec.pid = int32(v.Value().At(0))
370+ break
371+ }
372+ }
373+ }
374+ rec.pending &^= propClientIDs
375+ x.checkReady(s)
376+}
A internal/adapters/wayland/xwm/selection.go +1032 -0
@@ -0,0 +1,1032 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "time"
7+
8+ "bnema.dev/xturbo"
9+ "bnema.dev/xturbo/xfixes"
10+ "bnema.dev/xturbo/xproto"
11+ "golang.org/x/sys/unix"
12+)
13+
14+// The selection bridge: text-only CLIPBOARD and PRIMARY between X clients and
15+// the Wayland clipboard. Display goroutine only; nothing here is locked.
16+
17+// Selection kinds, in the order clipboard.go uses.
18+const (
19+ SelClipboard = iota
20+ SelPrimary
21+ selKinds
22+)
23+
24+// textMimes are the MIME types the bridge offers, in the order of preference;
25+// mimeTarget gives the atom an X owner must advertise for each.
26+var (
27+ textMimes = [5]string{"text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT"}
28+ mimeTarget = [5]int{atomUTF8String, atomString, atomUTF8String, atomString, atomUTF8String}
29+)
30+
31+// selState is one selection.
32+type selState struct {
33+ atom uint32
34+ gen uint32 // bumped on every ownership change; stales TARGETS probes
35+ owner uint32 // X owner window other than selWin, 0 none
36+ ours bool // selWin owns the selection (Wayland -> X)
37+ // wl is what the Wayland side offers while ours: raw text availability.
38+ wlUTF8, wlPlain bool
39+ wlMime [len(textMimes)]string // offered text MIMEs, in textMimes order, "" absent
40+}
41+
42+const (
43+ chunk = 65536 // every transfer step moves at most this many bytes
44+ nXfer = 4 // concurrent transfers per direction, and pool buffers
45+ tickEvery = time.Second
46+ xIdleMax = 10 // ticks without X-side progress
47+ rIdleMax = 30 // ticks of a reader that does not drain its pipe
48+)
49+
50+// bufPool is the fixed set of transfer buffers.
51+type bufPool struct {
52+ bufs [nXfer][chunk]byte
53+ free [nXfer]int8
54+ n int
55+}
56+
57+func (p *bufPool) init() {
58+ for i := range p.free {
59+ p.free[i] = int8(i)
60+ }
61+ p.n = nXfer
62+}
63+
64+// get returns a free buffer index, -1 when the pool is exhausted.
65+func (p *bufPool) get() int {
66+ if p.n == 0 {
67+ return -1
68+ }
69+ p.n--
70+ return int(p.free[p.n])
71+}
72+
73+func (p *bufPool) put(i int) {
74+ p.free[p.n] = int8(i)
75+ p.n++
76+}
77+
78+// X -> Wayland transfer states.
79+const (
80+ inFree uint8 = iota
81+ inQueued // waiting for the conversion property to be free
82+ inConverting
83+ inWaitIncr // waiting for the next INCR chunk (PropertyNotify)
84+ inWriting // a buffer is pending on the Wayland reader's pipe
85+ inReading // a GetProperty is outstanding
86+)
87+
88+// xin is one X -> Wayland transfer.
89+type xin struct {
90+ x *XWM
91+ idx int
92+ state uint8
93+ kind int
94+ fd int
95+ target uint32
96+ order uint64
97+ gen uint32 // stales replies of an aborted transfer
98+ incr bool
99+ buf int
100+ off, n int
101+ words uint32 // long offset of the next piece of a long property
102+ more bool // the last piece left bytes in the property
103+ ready bool // the owner wrote the next INCR chunk while a buffer was pending
104+ idle int
105+ src fdSource
106+ onR func() // pipe hangup
107+ onW func() // pipe writable
108+}
109+
110+// selBridge is the XWM's selection state.
111+type selBridge struct {
112+ st [selKinds]selState
113+ mimes [len(textMimes)]string // scratch handed to SelectionOwned
114+ pool bufPool
115+ in [nXfer]xin
116+ out [nXfer]xout
117+ order uint64
118+ // active is the transfer that owns the conversion property, -1 none.
119+ active int
120+ timerOn bool
121+ cancel func()
122+ tickFn func()
123+}
124+
125+func (x *XWM) initSelection() {
126+ b := &x.sel
127+ b.pool.init()
128+ b.active = -1
129+ b.tickFn = x.selTick
130+ for i := range b.in {
131+ t := &b.in[i]
132+ t.x, t.idx, t.buf = x, i, -1
133+ t.onR = func() { t.x.inAbort(t.idx, true) }
134+ t.onW = func() { t.x.inWritable(t.idx) }
135+ }
136+ for i := range b.out {
137+ o := &b.out[i]
138+ o.x, o.idx, o.buf, o.fd = x, i, -1, -1
139+ o.onR = func() { o.x.outReadable(o.idx) }
140+ }
141+}
142+
143+// bootSelection creates the selection window and asks for ownership events.
144+// Without XFixes the bridge stays off.
145+func (x *XWM) bootSelection(root xproto.Window) {
146+ if !x.conn.Ext().Info(xturbo.ExtXFixes).Present {
147+ x.log.Warn().Msg("X server has no XFixes: clipboard bridge disabled")
148+ return
149+ }
150+ xid := x.selWin
151+ b := x.conn.Buf()
152+ var wv xproto.CreateWindowValues
153+ wv.SetEventMask(xproto.EventMaskPropertyChange)
154+ xproto.CreateWindow(b, 0, xproto.Window(xid), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOnly, 0, &wv)
155+ x.sel.st[SelClipboard].atom = x.atoms[atomClipboard]
156+ x.sel.st[SelPrimary].atom = x.atoms[atomPrimary]
157+ const mask = xfixes.SelectionEventMaskSetSelectionOwner | xfixes.SelectionEventMaskSelectionWindowDestroy | xfixes.SelectionEventMaskSelectionClientClose
158+ for k := range x.sel.st {
159+ xfixes.SelectSelectionInput(b, root, xproto.Atom(x.sel.st[k].atom), mask)
160+ }
161+}
162+
163+// selKind maps a selection atom to its kind, -1 when it is neither.
164+func (x *XWM) selKind(atom uint32) int {
165+ for k := range x.sel.st {
166+ if x.sel.st[k].atom == atom && atom != 0 {
167+ return k
168+ }
169+ }
170+ return -1
171+}
172+
173+// onSelectionEvent handles the selection events; others are ignored.
174+func (x *XWM) onSelectionEvent(ev *xturbo.Event) {
175+ if x.selWin == 0 {
176+ return
177+ }
178+ switch ev.Kind {
179+ case xfixes.KindSelectionNotify:
180+ x.onXFixesSelection(ev)
181+ case xproto.KindSelectionNotify:
182+ x.onSelectionNotify(ev)
183+ case xproto.KindSelectionRequest:
184+ x.onSelectionRequest(ev)
185+ case xproto.KindSelectionClear:
186+ x.onSelectionClear(ev)
187+ }
188+}
189+
190+// onXFixesSelection: the owner of a selection changed.
191+func (x *XWM) onXFixesSelection(ev *xturbo.Event) {
192+ e := xfixes.SelectionNotifyOf(ev)
193+ k := x.selKind(uint32(e.Selection()))
194+ if k < 0 {
195+ return
196+ }
197+ st := &x.sel.st[k]
198+ owner := uint32(e.Owner())
199+ if owner == x.selWin { // our own SetSelectionOwner echoed back
200+ return
201+ }
202+ st.gen++
203+ st.owner = owner
204+ if owner == 0 {
205+ x.log.Debug().Int("kind", k).Msg("selection: no X owner")
206+ x.host.SelectionOwned(k, nil)
207+ return
208+ }
209+ x.log.Debug().Int("kind", k).Uint32("owner", owner).Msg("selection: X owner")
210+ xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(st.atom),
211+ xproto.Atom(x.atoms[atomTargets]), xproto.Atom(x.atoms[atomNeferwlTargets]), 0)
212+ x.wake()
213+}
214+
215+// onSelectionNotify: a ConvertSelection we issued was answered.
216+func (x *XWM) onSelectionNotify(ev *xturbo.Event) {
217+ e := xproto.SelectionNotifyOf(ev)
218+ if uint32(e.Requestor()) != x.selWin {
219+ return
220+ }
221+ k := x.selKind(uint32(e.Selection()))
222+ if k < 0 {
223+ return
224+ }
225+ if uint32(e.Target()) != x.atoms[atomTargets] {
226+ x.onDataNotify(e)
227+ return
228+ }
229+ {
230+ if e.Property() == 0 {
231+ if x.sel.st[k].owner != 0 {
232+ x.host.SelectionOwned(k, nil)
233+ }
234+ return
235+ }
236+ seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlTargets]), 0, 0, 256)
237+ x.expect(seq, kindSelTargets, uint32(k), x.sel.st[k].gen)
238+ x.wake()
239+ return
240+ }
241+}
242+
243+// onTargetsReply turns the owner's TARGETS list into the MIME types offered
244+// to Wayland: the text subset, empty for images, files and the like.
245+func (x *XWM) onTargetsReply(r *xturbo.Reply) {
246+ k := int(r.Args[0])
247+ if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen {
248+ return
249+ }
250+ g, ok := xproto.ParseGetPropertyReply(r.Raw)
251+ if !ok {
252+ return
253+ }
254+ var have [atomCount]bool
255+ if g.Format() == 32 {
256+ v := g.Value()
257+ for i := 0; i+4 <= len(v); i += 4 {
258+ a := binary.LittleEndian.Uint32(v[i:])
259+ for _, t := range [...]int{atomUTF8String, atomString, atomText} {
260+ if a == x.atoms[t] {
261+ have[t] = true
262+ }
263+ }
264+ }
265+ }
266+ n := 0
267+ for i, m := range textMimes {
268+ if have[mimeTarget[i]] {
269+ x.sel.mimes[n] = m
270+ n++
271+ }
272+ }
273+ if n == 0 {
274+ x.host.SelectionOwned(k, nil)
275+ return
276+ }
277+ x.host.SelectionOwned(k, x.sel.mimes[:n])
278+}
279+
280+// ---- X -> Wayland ----
281+
282+// SelectionReceive: a Wayland reader wants the X selection kind as mime. The
283+// XWM takes fd (a write end it now owns) and closes it when done.
284+func (x *XWM) SelectionReceive(kind int, mime string, fd int) {
285+ target := uint32(0)
286+ for i, m := range textMimes {
287+ if m == mime {
288+ target = x.atoms[mimeTarget[i]]
289+ break
290+ }
291+ }
292+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || target == 0 || x.sel.st[kind].owner == 0 {
293+ _ = unix.Close(fd)
294+ return
295+ }
296+ if unix.SetNonblock(fd, true) != nil {
297+ _ = unix.Close(fd)
298+ return
299+ }
300+ for i := range x.sel.in {
301+ t := &x.sel.in[i]
302+ if t.state != inFree {
303+ continue
304+ }
305+ x.sel.order++
306+ t.state, t.kind, t.fd, t.target, t.order = inQueued, kind, fd, target, x.sel.order
307+ t.incr, t.buf, t.off, t.n, t.words, t.more, t.ready, t.idle, t.src = false, -1, 0, 0, 0, false, false, 0, nil
308+ x.selEnsureTimer()
309+ x.inStartNext()
310+ return
311+ }
312+ x.log.Warn().Msg("selection: too many transfers, paste refused")
313+ _ = unix.Close(fd)
314+}
315+
316+// inStartNext starts the oldest queued transfer when none is converting.
317+func (x *XWM) inStartNext() {
318+ b := &x.sel
319+ if b.active >= 0 {
320+ return
321+ }
322+ best := -1
323+ for i := range b.in {
324+ if b.in[i].state == inQueued && (best < 0 || b.in[i].order < b.in[best].order) {
325+ best = i
326+ }
327+ }
328+ if best < 0 {
329+ return
330+ }
331+ t := &b.in[best]
332+ t.state, t.idle = inConverting, 0
333+ b.active = best
334+ xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(b.st[t.kind].atom),
335+ xproto.Atom(t.target), xproto.Atom(x.atoms[atomNeferwlData]), 0)
336+ x.wake()
337+}
338+
339+// inFinish ends transfer i and starts the next one.
340+func (x *XWM) inFinish(i int) {
341+ b := &x.sel
342+ t := &b.in[i]
343+ if t.src != nil {
344+ t.src.Remove()
345+ t.src = nil
346+ }
347+ if t.fd >= 0 {
348+ _ = unix.Close(t.fd)
349+ }
350+ if t.buf >= 0 {
351+ b.pool.put(t.buf)
352+ t.buf = -1
353+ }
354+ t.gen++
355+ t.state, t.fd = inFree, -1
356+ if b.active == i {
357+ b.active = -1
358+ }
359+ x.inStartNext()
360+}
361+
362+// inAbort drops transfer i, deleting the conversion property when X may still
363+// be writing it.
364+func (x *XWM) inAbort(i int, del bool) {
365+ t := &x.sel.in[i]
366+ if t.state == inFree {
367+ return
368+ }
369+ if del && x.sel.active == i && !x.closed {
370+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]))
371+ x.wake()
372+ }
373+ x.inFinish(i)
374+}
375+
376+// onDataNotify: the owner answered our data ConvertSelection.
377+func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) {
378+ i := x.sel.active
379+ if i < 0 {
380+ return
381+ }
382+ t := &x.sel.in[i]
383+ if t.state != inConverting || uint32(e.Target()) != t.target {
384+ return
385+ }
386+ if e.Property() == 0 {
387+ x.inFinish(i)
388+ return
389+ }
390+ t.words = 0
391+ x.inRequest(i)
392+}
393+
394+// inRequest asks for the next piece of the property; delete makes INCR and
395+// whole-value reads advance.
396+func (x *XWM) inRequest(i int) {
397+ t := &x.sel.in[i]
398+ t.state = inReading
399+ seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]),
400+ 0, t.words, chunk/4)
401+ if seq != 0 {
402+ if err := x.conn.Expect(seq, kindSelData, uint32(i), t.gen, 0, 0); err != nil {
403+ x.stats.RingFull++
404+ }
405+ }
406+ x.wake()
407+}
408+
409+// onSelDataReply handles one piece of the converted property.
410+func (x *XWM) onSelDataReply(r *xturbo.Reply) {
411+ i := int(r.Args[0])
412+ if i < 0 || i >= nXfer {
413+ return
414+ }
415+ t := &x.sel.in[i]
416+ if t.state != inReading || t.gen != r.Args[1] {
417+ return
418+ }
419+ g, ok := xproto.ParseGetPropertyReply(r.Raw)
420+ if !ok || g.Format() == 0 {
421+ x.inAbort(i, false)
422+ return
423+ }
424+ t.idle = 0
425+ if uint32(g.Type()) == x.atoms[atomIncr] {
426+ t.incr, t.state, t.words, t.more = true, inWaitIncr, 0, false
427+ return
428+ }
429+ v := g.Value()
430+ if g.Format() != 8 || len(v) > chunk {
431+ x.inAbort(i, true)
432+ return
433+ }
434+ t.more = g.BytesAfter() > 0
435+ if len(v) == 0 {
436+ if t.more { // cannot happen: bytes remain only after a non-empty read
437+ x.inAbort(i, true)
438+ return
439+ }
440+ x.inNext(i)
441+ return
442+ }
443+ b := x.sel.pool.get()
444+ if b < 0 {
445+ x.log.Warn().Msg("selection: no free buffer, paste aborted")
446+ x.inAbort(i, true)
447+ return
448+ }
449+ t.buf = b
450+ t.n = copy(x.sel.pool.bufs[b][:], v)
451+ t.off = 0
452+ t.words += uint32(t.n) / 4
453+ t.state = inWriting
454+ x.inDrain(i)
455+}
456+
457+// inDrain writes the pending buffer to the reader's pipe.
458+func (x *XWM) inDrain(i int) {
459+ t := &x.sel.in[i]
460+ buf := &x.sel.pool.bufs[t.buf]
461+ for t.off < t.n {
462+ n, err := unix.Write(t.fd, buf[t.off:t.n])
463+ switch {
464+ case err == nil:
465+ t.off += n
466+ t.idle = 0
467+ case errors.Is(err, unix.EINTR):
468+ case errors.Is(err, unix.EAGAIN):
469+ x.inWatch(i, true)
470+ return
471+ default: // EPIPE: the reader went away
472+ x.inAbort(i, true)
473+ return
474+ }
475+ }
476+ x.sel.pool.put(t.buf)
477+ t.buf = -1
478+ if t.src != nil {
479+ x.inWatch(i, false)
480+ }
481+ x.inNext(i)
482+}
483+
484+// inWatch arms or disarms writable interest on the reader's pipe.
485+func (x *XWM) inWatch(i int, on bool) {
486+ t := &x.sel.in[i]
487+ if t.src == nil {
488+ if !on || x.addFD == nil {
489+ if on {
490+ x.inAbort(i, true)
491+ }
492+ return
493+ }
494+ src, err := x.addFD(t.fd, t.onR, t.onW)
495+ if err != nil {
496+ x.inAbort(i, true)
497+ return
498+ }
499+ t.src = src
500+ }
501+ if err := t.src.SetWritable(on); err != nil {
502+ x.inAbort(i, true)
503+ }
504+}
505+
506+// inWritable: the pipe has room again.
507+func (x *XWM) inWritable(i int) {
508+ t := &x.sel.in[i]
509+ if t.state == inWriting && t.buf >= 0 {
510+ x.inDrain(i)
511+ }
512+}
513+
514+// inNext decides what follows a drained (or empty) piece.
515+func (x *XWM) inNext(i int) {
516+ t := &x.sel.in[i]
517+ switch {
518+ case t.more: // the rest of this property
519+ x.inRequest(i)
520+ case t.incr && t.n > 0:
521+ t.state, t.n, t.words = inWaitIncr, 0, 0
522+ if t.ready {
523+ t.ready = false
524+ x.inRequest(i)
525+ }
526+ default: // single value done, or the empty INCR terminator
527+ x.inFinish(i)
528+ }
529+}
530+
531+// onSelProperty: PropertyNotify on the selection window.
532+func (x *XWM) onSelProperty(ev *xturbo.Event) {
533+ e := xproto.PropertyNotifyOf(ev)
534+ if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] {
535+ return
536+ }
537+ // (a Deleted state on a requestor is handled by onRequestorProperty)
538+ i := x.sel.active
539+ if i < 0 || !x.sel.in[i].incr {
540+ return
541+ }
542+ t := &x.sel.in[i]
543+ switch t.state {
544+ case inWaitIncr:
545+ t.idle = 0
546+ t.n = 0
547+ x.inRequest(i)
548+ case inWriting, inReading:
549+ t.ready = true // no request while a buffer is pending: backpressure
550+ }
551+}
552+
553+// onSelError: a selection request failed; the awaited reply never comes.
554+func (x *XWM) onSelError(e *xturbo.XError) {
555+ if e.Kind == kindSelOut {
556+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.out[i].gen == e.Args[1] && x.sel.out[i].state != outFree {
557+ x.outAbort(i)
558+ }
559+ return
560+ }
561+ if e.Kind == kindSelData {
562+ if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading {
563+ x.inAbort(i, false)
564+ }
565+ }
566+}
567+
568+// ---- timeouts ----
569+
570+func (x *XWM) selEnsureTimer() {
571+ b := &x.sel
572+ if b.timerOn || x.after == nil {
573+ return
574+ }
575+ b.timerOn = true
576+ b.cancel = x.after(tickEvery, b.tickFn)
577+}
578+
579+// selTick ages every transfer by one second and aborts the stalled ones.
580+func (x *XWM) selTick() {
581+ b := &x.sel
582+ b.timerOn, b.cancel = false, nil
583+ if x.closed {
584+ return
585+ }
586+ live := false
587+ for i := range b.in {
588+ t := &b.in[i]
589+ if t.state == inFree {
590+ continue
591+ }
592+ t.idle++
593+ limit := xIdleMax
594+ if t.state == inWriting {
595+ limit = rIdleMax
596+ }
597+ if t.state != inQueued && t.idle >= limit {
598+ x.log.Warn().Int("kind", t.kind).Msg("selection: transfer timed out")
599+ x.inAbort(i, true)
600+ continue
601+ }
602+ live = true
603+ }
604+ for i := range b.out {
605+ o := &b.out[i]
606+ if o.state == outFree {
607+ continue
608+ }
609+ o.idle++
610+ limit := rIdleMax
611+ if o.incr && o.n > 0 && !o.credit {
612+ limit = xIdleMax // the requestor does not delete the property
613+ }
614+ if o.idle >= limit {
615+ x.log.Warn().Int("kind", o.kind).Msg("selection: transfer timed out")
616+ x.outAbort(i)
617+ continue
618+ }
619+ live = true
620+ }
621+ if live {
622+ x.selEnsureTimer()
623+ }
624+}
625+
626+// selClose aborts every transfer.
627+func (x *XWM) selClose() {
628+ for i := range x.sel.out {
629+ if x.sel.out[i].state != outFree {
630+ x.outAbort(i)
631+ }
632+ }
633+ for i := range x.sel.in {
634+ if x.sel.in[i].state != inFree {
635+ x.inAbort(i, false)
636+ }
637+ }
638+ if x.sel.cancel != nil {
639+ x.sel.cancel()
640+ x.sel.cancel = nil
641+ }
642+}
643+
644+// ---- Wayland -> X ----
645+
646+// Wayland -> X transfer states.
647+const (
648+ outFree uint8 = iota
649+ outActive
650+)
651+
652+// xout is one Wayland -> X transfer.
653+type xout struct {
654+ x *XWM
655+ idx int
656+ state uint8
657+ kind int
658+ requestor uint32
659+ property uint32
660+ target uint32
661+ time uint32
662+ fd int
663+ buf int
664+ n int
665+ eof bool
666+ incr bool // INCR announced
667+ credit bool // the requestor deleted the property: the next chunk may go
668+ notified bool // the requestor got its SelectionNotify
669+ idle int
670+ gen uint32
671+ src fdSource
672+ onR func()
673+}
674+
675+// WaylandSelection: a Wayland client owns kind with these MIME types, or none
676+// does (mimes nil). The XWM takes X ownership when text is offered.
677+func (x *XWM) WaylandSelection(kind int, mimes []string) {
678+ if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || x.host == nil {
679+ return
680+ }
681+ st := &x.sel.st[kind]
682+ st.wlUTF8, st.wlPlain = false, false
683+ st.wlMime = [len(textMimes)]string{}
684+ for _, m := range mimes {
685+ for i, tm := range textMimes {
686+ if m != tm {
687+ continue
688+ }
689+ st.wlMime[i] = m
690+ if mimeTarget[i] == atomUTF8String {
691+ st.wlUTF8 = true
692+ } else {
693+ st.wlPlain = true
694+ }
695+ }
696+ }
697+ for i := range x.sel.out { // the data being served is stale
698+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == kind {
699+ x.outAbort(i)
700+ }
701+ }
702+ b := x.conn.Buf()
703+ if !st.wlUTF8 && !st.wlPlain {
704+ if st.ours {
705+ st.ours = false
706+ xproto.SetSelectionOwner(b, 0, xproto.Atom(st.atom), 0)
707+ x.wake()
708+ }
709+ return
710+ }
711+ st.ours = true
712+ st.gen++
713+ st.owner = 0
714+ xproto.SetSelectionOwner(b, xproto.Window(x.selWin), xproto.Atom(st.atom), 0)
715+ x.log.Debug().Int("kind", kind).Msg("selection: Wayland owner")
716+ x.wake()
717+}
718+
719+// onSelectionClear: someone else took a selection from selWin.
720+func (x *XWM) onSelectionClear(ev *xturbo.Event) {
721+ e := xproto.SelectionClearOf(ev)
722+ k := x.selKind(uint32(e.Selection()))
723+ if k < 0 || uint32(e.Owner()) != x.selWin {
724+ return
725+ }
726+ x.sel.st[k].ours = false
727+ for i := range x.sel.out {
728+ if x.sel.out[i].state != outFree && x.sel.out[i].kind == k {
729+ x.outAbort(i)
730+ }
731+ }
732+}
733+
734+// notify sends a SelectionNotify to the requestor; property 0 refuses.
735+func (x *XWM) notify(requestor, selection, target, property, time uint32) {
736+ d := xproto.SelectionNotifyEventData{
737+ Time: xproto.Timestamp(time), Requestor: xproto.Window(requestor),
738+ Selection: xproto.Atom(selection), Target: xproto.Atom(target), Property: xproto.Atom(property),
739+ }
740+ var ev [32]byte
741+ d.Put(&ev)
742+ xproto.SendEvent(x.conn.Buf(), false, xproto.Window(requestor), 0, &ev)
743+ x.wake()
744+}
745+
746+// onSelectionRequest: an X client asks selWin for the Wayland selection.
747+func (x *XWM) onSelectionRequest(ev *xturbo.Event) {
748+ e := xproto.SelectionRequestOf(ev)
749+ if uint32(e.Owner()) != x.selWin {
750+ return
751+ }
752+ sel, req, target, prop, time := uint32(e.Selection()), uint32(e.Requestor()), uint32(e.Target()), uint32(e.Property()), uint32(e.Time())
753+ k := x.selKind(sel)
754+ if k < 0 || !x.sel.st[k].ours {
755+ x.notify(req, sel, target, 0, time)
756+ return
757+ }
758+ if prop == 0 { // obsolete client
759+ prop = target
760+ }
761+ st := &x.sel.st[k]
762+ switch target {
763+ case x.atoms[atomTargets]:
764+ var list [5]uint32
765+ n := 0
766+ list[n], n = x.atoms[atomTargets], n+1
767+ if st.wlUTF8 {
768+ list[n], n = x.atoms[atomUTF8String], n+1
769+ list[n], n = x.atoms[atomText], n+1
770+ }
771+ list[n], n = x.atoms[atomString], n+1
772+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(req), xproto.Atom(prop),
773+ xproto.AtomAtom, 32, uint32(n), x.encode32(list[:n]))
774+ x.notify(req, sel, target, prop, time)
775+ return
776+ case x.atoms[atomUTF8String], x.atoms[atomText], x.atoms[atomString]:
777+ default:
778+ x.notify(req, sel, target, 0, time)
779+ return
780+ }
781+ // Choose the Wayland MIME: UTF-8 targets prefer the UTF-8 types; STRING
782+ // prefers plain text and falls back to the UTF-8 bytes unchanged.
783+ mime := ""
784+ order := [...]int{0, 2, 4, 1, 3} // utf8 types, then plain
785+ if target == x.atoms[atomString] {
786+ order = [...]int{1, 3, 0, 2, 4}
787+ }
788+ for _, i := range order {
789+ if st.wlMime[i] != "" && (target == x.atoms[atomString] || mimeTarget[i] == atomUTF8String) {
790+ mime = st.wlMime[i]
791+ break
792+ }
793+ }
794+ if mime == "" {
795+ x.notify(req, sel, target, 0, time)
796+ return
797+ }
798+ for i := range x.sel.out { // one transfer per (requestor, property)
799+ o := &x.sel.out[i]
800+ if o.state != outFree && o.requestor == req && o.property == prop {
801+ x.notify(req, sel, target, 0, time)
802+ return
803+ }
804+ }
805+ slot := -1
806+ for i := range x.sel.out {
807+ if x.sel.out[i].state == outFree {
808+ slot = i
809+ break
810+ }
811+ }
812+ if slot < 0 || x.sel.pool.n == 0 {
813+ x.log.Warn().Msg("selection: too many transfers, request refused")
814+ x.notify(req, sel, target, 0, time)
815+ return
816+ }
817+ fd := x.host.SelectionRead(k, mime)
818+ if fd < 0 {
819+ x.notify(req, sel, target, 0, time)
820+ return
821+ }
822+ x.startOut(slot, k, req, prop, target, time, fd)
823+}
824+
825+// startOut begins serving the data on fd (a non-blocking read end it now
826+// owns) to requestor.
827+func (x *XWM) startOut(slot, k int, req, prop, target, time uint32, fd int) {
828+ o := &x.sel.out[slot]
829+ o.state, o.kind, o.requestor, o.property, o.target, o.time = outActive, k, req, prop, target, time
830+ o.fd, o.buf, o.n, o.eof, o.incr, o.credit, o.notified, o.idle, o.src = fd, x.sel.pool.get(), 0, false, false, false, false, 0, nil
831+ x.selEnsureTimer()
832+ x.outRead(slot) // data may already be waiting
833+}
834+
835+// encode32 packs vals into the shared scratch.
836+func (x *XWM) encode32(vals []uint32) []byte {
837+ n := 4 * len(vals)
838+ if cap(x.scratch) < n {
839+ x.scratch = make([]byte, 0, n)
840+ }
841+ p := x.scratch[:n]
842+ for i, v := range vals {
843+ binary.LittleEndian.PutUint32(p[4*i:], v)
844+ }
845+ return p
846+}
847+
848+// outWatch registers the pipe's read end with the display.
849+func (x *XWM) outWatch(i int) {
850+ o := &x.sel.out[i]
851+ if o.src != nil {
852+ return
853+ }
854+ if x.addFD == nil {
855+ x.outAbort(i)
856+ return
857+ }
858+ src, err := x.addFD(o.fd, o.onR, nil)
859+ if err != nil {
860+ x.outAbort(i)
861+ return
862+ }
863+ o.src = src
864+}
865+
866+// outUnwatch drops read interest: the buffer is full and nothing may be read
867+// until the requestor consumed the previous chunk.
868+func (x *XWM) outUnwatch(i int) {
869+ o := &x.sel.out[i]
870+ if o.src != nil {
871+ o.src.Remove()
872+ o.src = nil
873+ }
874+}
875+
876+// outReadable is the pipe's read callback.
877+func (x *XWM) outReadable(i int) {
878+ if x.sel.out[i].state == outFree {
879+ return
880+ }
881+ x.outRead(i)
882+}
883+
884+// outRead fills the buffer from the pipe and advances the transfer.
885+func (x *XWM) outRead(i int) {
886+ o := &x.sel.out[i]
887+ buf := &x.sel.pool.bufs[o.buf]
888+ for o.n < chunk && !o.eof {
889+ n, err := unix.Read(o.fd, buf[o.n:])
890+ switch {
891+ case err == nil && n == 0:
892+ o.eof = true
893+ case err == nil:
894+ o.n += n
895+ o.idle = 0
896+ case errors.Is(err, unix.EINTR):
897+ continue
898+ case errors.Is(err, unix.EAGAIN):
899+ x.outPump(i)
900+ return
901+ default:
902+ x.outAbort(i)
903+ return
904+ }
905+ }
906+ x.outPump(i)
907+}
908+
909+// outPump sends what the state allows.
910+func (x *XWM) outPump(i int) {
911+ o := &x.sel.out[i]
912+ if o.state == outFree {
913+ return
914+ }
915+ full := o.n == chunk
916+ if !o.incr {
917+ switch {
918+ case o.eof: // whole value
919+ x.outSetProp(i, o.n)
920+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
921+ x.outFinish(i)
922+ case full: // does not fit one request: INCR
923+ o.incr, o.notified = true, true
924+ var v xproto.ChangeWindowAttributesValues
925+ v.SetEventMask(xproto.EventMaskPropertyChange)
926+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
927+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
928+ xproto.Atom(x.atoms[atomIncr]), 32, 1, x.encode32([]uint32{0}))
929+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time)
930+ x.outUnwatch(i)
931+ default:
932+ x.outWatch(i)
933+ }
934+ return
935+ }
936+ if o.credit && (full || o.eof) {
937+ o.credit = false
938+ o.idle = 0
939+ x.outSetProp(i, o.n)
940+ if o.n == 0 { // the empty chunk ends the transfer
941+ x.outFinish(i)
942+ return
943+ }
944+ o.n = 0
945+ }
946+ if o.n < chunk && !o.eof {
947+ x.outWatch(i)
948+ } else {
949+ x.outUnwatch(i)
950+ }
951+}
952+
953+// outSetProp writes the first n buffered bytes as the property value.
954+func (x *XWM) outSetProp(i, n int) {
955+ o := &x.sel.out[i]
956+ seq := xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property),
957+ xproto.Atom(o.target), 8, uint32(n), x.sel.pool.bufs[o.buf][:n])
958+ if seq != 0 {
959+ if err := x.conn.Expect(seq, kindSelOut, uint32(i), o.gen, 0, 0); err != nil {
960+ x.stats.RingFull++
961+ }
962+ }
963+ x.wake()
964+}
965+
966+// outFinish ends a completed transfer.
967+func (x *XWM) outFinish(i int) {
968+ o := &x.sel.out[i]
969+ if o.incr && !x.closed {
970+ var v xproto.ChangeWindowAttributesValues
971+ v.SetEventMask(0)
972+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
973+ x.wake()
974+ }
975+ x.outRelease(i)
976+}
977+
978+// outRelease frees the transfer's resources.
979+func (x *XWM) outRelease(i int) {
980+ o := &x.sel.out[i]
981+ x.outUnwatch(i)
982+ if o.fd >= 0 {
983+ _ = unix.Close(o.fd)
984+ }
985+ if o.buf >= 0 {
986+ x.sel.pool.put(o.buf)
987+ }
988+ o.fd, o.buf = -1, -1
989+ o.gen++
990+ o.state = outFree
991+}
992+
993+// outAbort ends a transfer early: the requestor is refused (before its
994+// notification) or released (INCR: mask dropped, property deleted).
995+func (x *XWM) outAbort(i int) {
996+ o := &x.sel.out[i]
997+ if o.state == outFree {
998+ return
999+ }
1000+ if !x.closed {
1001+ switch {
1002+ case !o.notified:
1003+ x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, 0, o.time)
1004+ case o.incr:
1005+ var v xproto.ChangeWindowAttributesValues
1006+ v.SetEventMask(0)
1007+ xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v)
1008+ xproto.DeleteProperty(x.conn.Buf(), xproto.Window(o.requestor), xproto.Atom(o.property))
1009+ x.wake()
1010+ }
1011+ }
1012+ x.outRelease(i)
1013+}
1014+
1015+// onRequestorProperty: PropertyNotify on a window we serve by INCR.
1016+func (x *XWM) onRequestorProperty(e xproto.PropertyNotifyEvent) bool {
1017+ if e.State() != xproto.PropertyDelete {
1018+ return false
1019+ }
1020+ hit := false
1021+ for i := range x.sel.out {
1022+ o := &x.sel.out[i]
1023+ if o.state == outFree || !o.incr || o.requestor != uint32(e.Window()) || o.property != uint32(e.Atom()) {
1024+ continue
1025+ }
1026+ hit = true
1027+ o.credit = true
1028+ o.idle = 0
1029+ x.outPump(i)
1030+ }
1031+ return hit
1032+}
A internal/adapters/wayland/xwm/selection_test.go +685 -0
@@ -0,0 +1,685 @@
1+package xwm
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "io"
7+ "os"
8+ "testing"
9+ "time"
10+
11+ "bnema.dev/xturbo"
12+ "bnema.dev/xturbo/xproto"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+ "golang.org/x/sys/unix"
16+)
17+
18+const peerXFixesFirstEvent = 87
19+
20+// xfixesSelection sends an XFixesSelectionNotify.
21+func (p *xpeer) xfixesSelection(owner, selection uint32) {
22+ p.event(peerXFixesFirstEvent, func(b *[32]byte) {
23+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
24+ binary.LittleEndian.PutUint32(b[8:], owner)
25+ binary.LittleEndian.PutUint32(b[12:], selection)
26+ })
27+}
28+
29+// selectionNotify sends a core SelectionNotify to the requestor.
30+func (p *xpeer) selectionNotify(requestor, selection, target, property uint32) {
31+ p.event(31, func(b *[32]byte) {
32+ binary.LittleEndian.PutUint32(b[8:], requestor)
33+ binary.LittleEndian.PutUint32(b[12:], selection)
34+ binary.LittleEndian.PutUint32(b[16:], target)
35+ binary.LittleEndian.PutUint32(b[20:], property)
36+ })
37+}
38+
39+// pumpReq dispatches X events until a request matching match was sent after
40+// the first skip requests.
41+func pumpReq(t *testing.T, x *XWM, p *xpeer, skip int, match func(xpeerReq) bool) (xpeerReq, int) {
42+ t.Helper()
43+ var found xpeerReq
44+ next := skip
45+ readUntil(t, x, func() bool {
46+ x.flush()
47+ reqs := p.requests()
48+ for i := skip; i < len(reqs); i++ {
49+ if match(reqs[i]) {
50+ found, next = reqs[i], i+1
51+ return true
52+ }
53+ }
54+ return false
55+ })
56+ return found, next
57+}
58+
59+func TestSelectionBoot(t *testing.T) {
60+ x, p := bootPeer(t, nil)
61+ require.NotZero(t, x.selWin)
62+ reqs := p.requests()
63+ var created, selected int
64+ for _, r := range reqs {
65+ switch {
66+ case r.major == opCreateWindow && r.u32(4) == x.selWin:
67+ created++
68+ require.EqualValues(t, 2, binary.LittleEndian.Uint16(r.raw[22:]), "InputOnly")
69+ case r.major == 138 && r.minor == 2: // SelectSelectionInput
70+ selected++
71+ require.EqualValues(t, peerRoot, r.u32(4))
72+ require.Contains(t, []uint32{p.atom("CLIPBOARD"), p.atom("PRIMARY")}, r.u32(8))
73+ require.EqualValues(t, 7, r.u32(12))
74+ }
75+ }
76+ require.Equal(t, 1, created)
77+ require.Equal(t, 2, selected)
78+}
79+
80+func TestSelectionXOwnsAnnounces(t *testing.T) {
81+ x, p, h, _, _ := attached(t)
82+ const owner = 0x500010
83+ clip, prim := p.atom("CLIPBOARD"), p.atom("PRIMARY")
84+ tgt := p.atom("TARGETS")
85+
86+ // Owner with text targets: TARGETS probe, then the intersection.
87+ p.xfixesSelection(owner, clip)
88+ req, n := pumpReq(t, x, p, 0, func(r xpeerReq) bool { return r.major == 24 }) // ConvertSelection
89+ require.Equal(t, x.selWin, req.u32(4))
90+ require.Equal(t, clip, req.u32(8))
91+ require.Equal(t, tgt, req.u32(12))
92+ prop := req.u32(16)
93+ require.Equal(t, p.atom("_NEFERWL_TARGETS"), prop)
94+ p.setProp(x.selWin, prop, 4, 32, le32(tgt, p.atom("UTF8_STRING"), p.atom("image/png"), p.atom("TEXT")))
95+ got := make(chan []string, 8)
96+ h.EXPECT().SelectionOwned(SelClipboard, []string{"text/plain;charset=utf-8", "UTF8_STRING", "TEXT"}).Run(func(int, []string) { got <- nil }).Once()
97+ p.selectionNotify(x.selWin, clip, tgt, prop)
98+ readUntil(t, x, func() bool { return len(got) == 1 })
99+ pumpReq(t, x, p, n, func(r xpeerReq) bool { return r.major == opGetProperty && r.u32(8) == prop })
100+
101+ // Only image/png: nothing offered.
102+ p.xfixesSelection(owner, prim)
103+ pumpReq(t, x, p, n, func(r xpeerReq) bool { return r.major == 24 && r.u32(8) == prim })
104+ p.setProp(x.selWin, prop, 4, 32, le32(tgt, p.atom("image/png")))
105+ h.EXPECT().SelectionOwned(SelPrimary, []string(nil)).Run(func(int, []string) { got <- nil }).Once()
106+ p.selectionNotify(x.selWin, prim, tgt, prop)
107+ readUntil(t, x, func() bool { return len(got) == 2 })
108+
109+ // Owner None.
110+ h.EXPECT().SelectionOwned(SelClipboard, []string(nil)).Run(func(int, []string) { got <- nil }).Once()
111+ p.xfixesSelection(0, clip)
112+ readUntil(t, x, func() bool { return len(got) == 3 })
113+
114+ // Our own window: ignored (the mock fails on any host call).
115+ before := len(p.requests())
116+ p.xfixesSelection(x.selWin, clip)
117+ ev := x.Stats().Events
118+ readUntil(t, x, func() bool { return x.Stats().Events > ev })
119+ x.flush()
120+ require.Equal(t, before, len(p.requests()))
121+}
122+
123+// ---- X -> Wayland ----
124+
125+type selRig struct {
126+ x *XWM
127+ p *xpeer
128+ h *mockhost
129+ clip uint32
130+ utf8 uint32
131+ data uint32
132+ skip int
133+ timer func()
134+}
135+
136+// ownedRig attaches an XWM whose CLIPBOARD is owned by an X client offering text.
137+func ownedRig(t *testing.T) *selRig {
138+ t.Helper()
139+ x, p, h, _, _ := attached(t)
140+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
141+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
142+ got := 0
143+ h.EXPECT().SelectionOwned(SelClipboard, mock.Anything).Run(func(int, []string) { got++ }).Once()
144+ p.xfixesSelection(0x500010, r.clip)
145+ _, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == 24 })
146+ tgt := p.atom("TARGETS")
147+ p.setProp(x.selWin, p.atom("_NEFERWL_TARGETS"), 4, 32, le32(tgt, r.utf8))
148+ p.selectionNotify(x.selWin, r.clip, tgt, p.atom("_NEFERWL_TARGETS"))
149+ readUntil(t, x, func() bool { return got == 1 })
150+ r.skip = len(p.requests())
151+ _ = n
152+ return r
153+}
154+
155+// receive starts a paste and returns the read end of its pipe.
156+func (r *selRig) receive(t *testing.T, size int) *os.File {
157+ t.Helper()
158+ fds := make([]int, 2)
159+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
160+ if size > 0 {
161+ _, err := unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, size)
162+ require.NoError(t, err)
163+ }
164+ r.x.SelectionReceive(SelClipboard, "text/plain;charset=utf-8", fds[1])
165+ r.x.flush()
166+ f := os.NewFile(uintptr(fds[0]), "pipe-r")
167+ t.Cleanup(func() { f.Close() })
168+ return f
169+}
170+
171+func (r *selRig) waitGet(t *testing.T) {
172+ t.Helper()
173+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opGetProperty && q.u32(8) == r.data })
174+ r.skip = n
175+}
176+
177+func pattern(n int) []byte {
178+ b := make([]byte, n)
179+ for i := range b {
180+ b[i] = byte(i*7 + i/251)
181+ }
182+ return b
183+}
184+
185+func TestSelectionXToWaylandSmall(t *testing.T) {
186+ r := ownedRig(t)
187+ f := r.receive(t, 0)
188+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
189+ require.Equal(t, r.x.selWin, req.u32(4))
190+ require.Equal(t, r.clip, req.u32(8))
191+ require.Equal(t, r.utf8, req.u32(12))
192+ require.Equal(t, r.data, req.u32(16))
193+ r.skip = n
194+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, []byte("hello clipboard"))
195+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
196+ r.waitGet(t)
197+ buf := make([]byte, 64)
198+ var got []byte
199+ readUntil(t, r.x, func() bool {
200+ m, _ := unix.Read(int(f.Fd()), buf[:0:0])
201+ _ = m
202+ return r.x.sel.in[0].state == inFree
203+ })
204+ m, err := f.Read(buf)
205+ require.NoError(t, err)
206+ got = buf[:m]
207+ require.Equal(t, "hello clipboard", string(got))
208+ _, ok := r.p.prop(r.x.selWin, "_NEFERWL_DATA")
209+ require.False(t, ok, "property deleted by the read")
210+ _, err = f.Read(buf)
211+ require.ErrorIs(t, err, io.EOF, "write end closed")
212+}
213+
214+func TestSelectionXToWaylandRefused(t *testing.T) {
215+ r := ownedRig(t)
216+ f := r.receive(t, 0)
217+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
218+ r.skip = n
219+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, 0)
220+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
221+ _, err := f.Read(make([]byte, 8))
222+ require.ErrorIs(t, err, io.EOF)
223+}
224+
225+func TestSelectionXToWaylandINCR(t *testing.T) {
226+ r := ownedRig(t)
227+ const total = 1 << 20
228+ want := pattern(total)
229+ f := r.receive(t, 1<<20)
230+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
231+ r.skip = n
232+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(total))
233+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
234+ r.waitGet(t) // reads and deletes the INCR marker
235+ gets := 1
236+ for off := 0; off <= total; off += chunk {
237+ end := min(off+chunk, total)
238+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[off:end])
239+ r.p.propertyNotify(r.x.selWin, r.data, false)
240+ r.waitGet(t)
241+ gets++
242+ }
243+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inFree })
244+ got, err := io.ReadAll(f)
245+ require.NoError(t, err)
246+ require.True(t, bytes.Equal(want, got), "byte-exact, got %d", len(got))
247+ require.Equal(t, 1+total/chunk+1, gets, "marker, 16 chunks, empty terminator")
248+}
249+
250+// A reader that does not drain: no further GetProperty until the pipe has room.
251+func TestSelectionXToWaylandBackpressure(t *testing.T) {
252+ r := ownedRig(t)
253+ src := newMockfdSource(t)
254+ var onW func()
255+ r.x.addFD = func(_ int, _, w func()) (fdSource, error) { onW = w; return src, nil }
256+ src.EXPECT().SetWritable(true).Return(nil).Once()
257+ src.EXPECT().SetWritable(false).Return(nil).Once()
258+ src.EXPECT().Remove().Return().Once()
259+ f := r.receive(t, 0) // default 64 KiB pipe
260+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
261+ r.skip = n
262+ r.p.setProp(r.x.selWin, r.data, r.p.atom("INCR"), 32, le32(3*chunk))
263+ r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.data)
264+ r.waitGet(t)
265+ want := pattern(3 * chunk)
266+ chunkN := func(k int) {
267+ r.p.setProp(r.x.selWin, r.data, r.utf8, 8, want[k*chunk:(k+1)*chunk])
268+ r.p.propertyNotify(r.x.selWin, r.data, false)
269+ }
270+ chunkN(0) // fills the pipe exactly
271+ r.waitGet(t)
272+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWaitIncr })
273+ chunkN(1) // nobody reads: EAGAIN, the buffer stays pending
274+ r.waitGet(t)
275+ readUntil(t, r.x, func() bool { return r.x.sel.in[0].state == inWriting })
276+ require.NotNil(t, onW)
277+
278+ gets := getProps(r.p, 0, r.x.selWin)
279+ chunkN(2) // announced while a buffer is pending
280+ ev := r.x.Stats().Events
281+ readUntil(t, r.x, func() bool { return r.x.Stats().Events > ev })
282+ r.x.flush()
283+ require.Equal(t, gets, getProps(r.p, 0, r.x.selWin), "no GetProperty while a buffer is pending")
284+
285+ got := make([]byte, 0, 3*chunk)
286+ buf := make([]byte, chunk)
287+ m, err := f.Read(buf) // the reader wakes: room again
288+ require.NoError(t, err)
289+ got = append(got, buf[:m]...)
290+ onW()
291+ require.Equal(t, inReading, r.x.sel.in[0].state, "the pending announcement is served once drained")
292+ r.waitGet(t)
293+ _ = got
294+ r.x.selClose()
295+}
296+
297+func TestSelectionTimeouts(t *testing.T) {
298+ r := ownedRig(t)
299+ f := r.receive(t, 0)
300+ _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 })
301+ r.skip = n
302+ require.NotNil(t, r.timer)
303+ // X stays silent after ConvertSelection: nine ticks keep it alive, the tenth aborts.
304+ for i := 0; i < xIdleMax-1; i++ {
305+ fn := r.timer
306+ fn()
307+ require.Equal(t, inConverting, r.x.sel.in[0].state, i)
308+ }
309+ r.timer()
310+ require.Equal(t, inFree, r.x.sel.in[0].state)
311+ _, err := f.Read(make([]byte, 8))
312+ require.ErrorIs(t, err, io.EOF, "fd closed")
313+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(8) == r.data })
314+ require.Equal(t, nXfer, r.x.sel.pool.n)
315+}
316+
317+func TestSelectionPoolExhausted(t *testing.T) {
318+ r := ownedRig(t)
319+ var files []*os.File
320+ for i := 0; i < nXfer; i++ {
321+ files = append(files, r.receive(t, 0))
322+ }
323+ fifth := r.receive(t, 0)
324+ _, err := fifth.Read(make([]byte, 8))
325+ require.ErrorIs(t, err, io.EOF, "the fifth paste is refused with its pipe closed")
326+ for i := range r.x.sel.in {
327+ require.NotEqual(t, inFree, r.x.sel.in[i].state, i)
328+ }
329+ _ = files
330+ r.x.selClose()
331+ for i := range r.x.sel.in {
332+ require.Equal(t, inFree, r.x.sel.in[i].state, i)
333+ }
334+}
335+
336+// allocRig is a rig whose peer sinks requests and whose timer seam is inert.
337+func allocRig(t *testing.T) *selRig {
338+ r := ownedRig(t)
339+ sinkPeer(t, r.x, r.p)
340+ r.x.after = func(time.Duration, func()) func() { return func() {} }
341+ r.x.sel.timerOn = true // no timer re-arming in the measured loop
342+ return r
343+}
344+
345+func dataReply(r *selRig, typ uint32, payload []byte) *xturbo.Reply {
346+ raw := make([]byte, 32+(len(payload)+3)&^3)
347+ raw[0], raw[1] = 1, 8
348+ binary.LittleEndian.PutUint32(raw[8:], typ)
349+ binary.LittleEndian.PutUint32(raw[16:], uint32(len(payload)))
350+ copy(raw[32:], payload)
351+ return &xturbo.Reply{Kind: kindSelData, Raw: raw}
352+}
353+
354+func TestSelectionCopyAllocations(t *testing.T) {
355+ r := allocRig(t)
356+ x := r.x
357+ fds := make([]int, 2)
358+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
359+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
360+ payload := pattern(4096)
361+ rep := dataReply(r, r.utf8, payload)
362+ var note xturbo.Event
363+ note.Kind = xproto.KindSelectionNotify
364+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
365+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
366+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
367+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
368+ sink := make([]byte, 8192)
369+ mime := textMimes[0]
370+ round := func() {
371+ fd, err := unix.FcntlInt(uintptr(fds[1]), unix.F_DUPFD_CLOEXEC, 0)
372+ if err != nil {
373+ t.Fatal("dup")
374+ }
375+ x.SelectionReceive(SelClipboard, mime, fd)
376+ x.onSelectionNotify(&note)
377+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
378+ x.onSelDataReply(rep)
379+ if m, err := unix.Read(fds[0], sink); err != nil || m != 4096 {
380+ t.Fatal("read")
381+ }
382+ x.posted = false
383+ x.flush()
384+ }
385+ round()
386+ require.Zero(t, testing.AllocsPerRun(100, round))
387+}
388+
389+func TestSelectionINCRChunkAllocations(t *testing.T) {
390+ r := allocRig(t)
391+ x := r.x
392+ fds := make([]int, 2)
393+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC))
394+ t.Cleanup(func() { unix.Close(fds[0]); unix.Close(fds[1]) })
395+ x.SelectionReceive(SelClipboard, textMimes[0], fds[1])
396+ var note xturbo.Event
397+ note.Kind = xproto.KindSelectionNotify
398+ binary.LittleEndian.PutUint32(note.Raw[8:], x.selWin)
399+ binary.LittleEndian.PutUint32(note.Raw[12:], r.clip)
400+ binary.LittleEndian.PutUint32(note.Raw[16:], r.utf8)
401+ binary.LittleEndian.PutUint32(note.Raw[20:], r.data)
402+ x.onSelectionNotify(&note)
403+ x.onSelDataReply(dataReply(r, r.p.atom("INCR"), le32(1<<20)))
404+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
405+ rep := dataReply(r, r.utf8, pattern(chunk))
406+ var pn xturbo.Event
407+ pn.Kind = xproto.KindPropertyNotify
408+ binary.LittleEndian.PutUint32(pn.Raw[4:], x.selWin)
409+ binary.LittleEndian.PutUint32(pn.Raw[8:], r.data)
410+ sink := make([]byte, chunk)
411+ step := func() {
412+ x.onSelProperty(&pn)
413+ rep.Args = [4]uint32{0, x.sel.in[0].gen}
414+ x.onSelDataReply(rep)
415+ if m, err := unix.Read(fds[0], sink); err != nil || m != chunk {
416+ t.Fatal("read")
417+ }
418+ x.posted = false
419+ x.flush()
420+ }
421+ step()
422+ require.Zero(t, testing.AllocsPerRun(100, step))
423+ require.Equal(t, inWaitIncr, x.sel.in[0].state)
424+}
425+
426+// ---- Wayland -> X ----
427+
428+const reqWin = 0x500020
429+
430+func (p *xpeer) selectionRequest(owner, requestor, selection, target, property uint32) {
431+ p.event(30, func(b *[32]byte) {
432+ binary.LittleEndian.PutUint32(b[8:], owner)
433+ binary.LittleEndian.PutUint32(b[12:], requestor)
434+ binary.LittleEndian.PutUint32(b[16:], selection)
435+ binary.LittleEndian.PutUint32(b[20:], target)
436+ binary.LittleEndian.PutUint32(b[24:], property)
437+ })
438+}
439+
440+func (p *xpeer) selectionClear(owner, selection uint32) {
441+ p.event(29, func(b *[32]byte) {
442+ binary.LittleEndian.PutUint32(b[8:], owner)
443+ binary.LittleEndian.PutUint32(b[12:], selection)
444+ })
445+}
446+
447+// sentNotify returns the property of the SelectionNotify SendEvent after skip.
448+func sentNotify(t *testing.T, r *selRig) (property uint32) {
449+ t.Helper()
450+ req, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSendEvent && q.raw[12] == 31 })
451+ r.skip = n
452+ require.EqualValues(t, reqWin, req.u32(4))
453+ require.Equal(t, r.clip, binary.LittleEndian.Uint32(req.raw[12+12:]))
454+ return binary.LittleEndian.Uint32(req.raw[12+20:])
455+}
456+
457+// ownedByWayland attaches an XWM and gives CLIPBOARD to a Wayland client offering mimes.
458+func ownedByWayland(t *testing.T, mimes ...string) *selRig {
459+ t.Helper()
460+ x, p, h, _, _ := attached(t)
461+ r := &selRig{x: x, p: p, h: h, clip: p.atom("CLIPBOARD"), utf8: p.atom("UTF8_STRING"), data: p.atom("_NEFERWL_DATA")}
462+ h.EXPECT().After(mock.Anything, mock.Anything).Run(func(_ time.Duration, fn func()) { r.timer = fn }).Return(func() {}).Maybe()
463+ x.WaylandSelection(SelClipboard, mimes)
464+ x.flush()
465+ req, n := pumpReq(t, x, p, 0, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(8) == r.clip })
466+ require.Equal(t, x.selWin, req.u32(4))
467+ r.skip = n
468+ return r
469+}
470+
471+// serve queues a SelectionRequest; SelectionRead hands out the read end of a
472+// pipe whose write end is returned.
473+func (r *selRig) serve(t *testing.T, target uint32) (w *os.File) {
474+ t.Helper()
475+ fds := make([]int, 2)
476+ require.NoError(t, unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK))
477+ r.h.EXPECT().SelectionRead(SelClipboard, mock.Anything).Return(fds[0]).Once()
478+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, target, r.data)
479+ w = os.NewFile(uintptr(fds[1]), "pipe-w")
480+ t.Cleanup(func() { w.Close() })
481+ return w
482+}
483+
484+func TestSelectionWaylandToXTargets(t *testing.T) {
485+ r := ownedByWayland(t, "text/plain")
486+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("TARGETS"), r.data)
487+ require.Equal(t, r.data, sentNotify(t, r))
488+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
489+ require.True(t, ok)
490+ require.Equal(t, []uint32{r.p.atom("TARGETS"), r.p.atom("STRING")}, atomsOf(v), "no UTF8 target for text/plain only")
491+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("image/png"), r.data)
492+ require.Zero(t, sentNotify(t, r), "unknown target refused")
493+ // UTF8_STRING is not offered by a text/plain-only source, but STRING reads it.
494+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
495+ require.Zero(t, sentNotify(t, r), "no UTF-8 text offered")
496+}
497+
498+func TestSelectionWaylandToXSmall(t *testing.T) {
499+ r := ownedByWayland(t, "text/plain;charset=utf-8")
500+ w := r.serve(t, r.utf8)
501+ data := pattern(4096)
502+ _, err := w.Write(data)
503+ require.NoError(t, err)
504+ require.NoError(t, w.Close())
505+ require.Equal(t, r.data, sentNotify(t, r))
506+ v, ok := r.p.prop(reqWin, "_NEFERWL_DATA")
507+ require.True(t, ok)
508+ require.Equal(t, r.utf8, v.typ)
509+ require.True(t, bytes.Equal(data, v.data))
510+ require.Equal(t, nXfer, r.x.sel.pool.n)
511+}
512+
513+func TestSelectionWaylandToXINCR(t *testing.T) {
514+ r := ownedByWayland(t, "text/plain;charset=utf-8")
515+ src := newMockfdSource(t)
516+ var onR func()
517+ removes := 0
518+ r.x.addFD = func(_ int, rd, _ func()) (fdSource, error) { onR = rd; return src, nil }
519+ src.EXPECT().Remove().Run(func() { removes++ }).Return().Maybe()
520+ const total = 1<<20 + 123
521+ data := pattern(total)
522+ w := r.serve(t, r.utf8)
523+ require.NoError(t, unix.SetNonblock(int(w.Fd()), false))
524+ go func() { _, _ = w.Write(data); w.Close() }()
525+
526+ // The first fill is a whole chunk: INCR is announced.
527+ var got []byte
528+ deadline := time.Now().Add(5 * time.Second)
529+ for !r.x.sel.out[0].incr {
530+ require.True(t, time.Now().Before(deadline), "INCR never announced")
531+ r.x.onReadable()
532+ if onR != nil && r.x.sel.out[0].state == outActive {
533+ onR()
534+ }
535+ time.Sleep(time.Millisecond)
536+ }
537+ require.Equal(t, r.data, sentNotify(t, r))
538+ v, _ := r.p.prop(reqWin, "_NEFERWL_DATA")
539+ require.Equal(t, r.p.atom("INCR"), v.typ)
540+ mask, _ := pumpReq(t, r.x, r.p, 0, func(q xpeerReq) bool {
541+ return q.major == opChangeWindowAttributes && q.u32(4) == reqWin && q.u32(12) == uint32(xproto.EventMaskPropertyChange)
542+ })
543+ require.NotZero(t, mask.raw)
544+
545+ chunks := 0
546+ for {
547+ // The requestor deletes the property: the next chunk goes out.
548+ r.p.propertyNotify(reqWin, r.data, true)
549+ before := len(r.p.requests())
550+ if onR != nil {
551+ time.Sleep(time.Millisecond)
552+ }
553+ var cp xpeerProp
554+ readUntil(t, r.x, func() bool {
555+ if onR != nil && r.x.sel.out[0].state != outFree && r.x.sel.out[0].n < chunk && !r.x.sel.out[0].eof {
556+ onR()
557+ }
558+ r.x.flush()
559+ for _, q := range r.p.requests()[before:] {
560+ if q.major == opChangeProperty && q.u32(4) == reqWin {
561+ return true
562+ }
563+ }
564+ return false
565+ })
566+ cp, _ = r.p.prop(reqWin, "_NEFERWL_DATA")
567+ chunks++
568+ if len(cp.data) == 0 {
569+ break
570+ }
571+ require.Equal(t, r.utf8, cp.typ)
572+ got = append(got, cp.data...)
573+ }
574+ require.True(t, bytes.Equal(data, got), "byte-exact: %d of %d", len(got), total)
575+ require.Equal(t, total/chunk+2, chunks, "16 full chunks, the tail, then the empty terminator")
576+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
577+ // The mask is removed at the end.
578+ reqs := r.p.requests()
579+ last := reqs[len(reqs)-1]
580+ for i := len(reqs) - 1; i >= 0; i-- {
581+ if reqs[i].major == opChangeWindowAttributes && reqs[i].u32(4) == reqWin {
582+ last = reqs[i]
583+ break
584+ }
585+ }
586+ require.Zero(t, last.u32(12), "event mask dropped")
587+ require.Equal(t, nXfer, r.x.sel.pool.n)
588+}
589+
590+func TestSelectionWaylandToXTimeoutAndClear(t *testing.T) {
591+ r := ownedByWayland(t, "text/plain;charset=utf-8")
592+ src := newMockfdSource(t)
593+ src.EXPECT().Remove().Return().Maybe()
594+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
595+ w := r.serve(t, r.utf8)
596+ _, err := w.Write(pattern(chunk)) // a full chunk: INCR
597+ require.NoError(t, err)
598+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
599+ require.Equal(t, r.data, sentNotify(t, r))
600+ require.NotNil(t, r.timer)
601+ for i := 0; i < xIdleMax; i++ {
602+ require.Equal(t, outActive, r.x.sel.out[0].state, i)
603+ r.timer()
604+ }
605+ require.Equal(t, outFree, r.x.sel.out[0].state, "no Deleted for 10 s aborts")
606+ pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opDeleteProperty && q.u32(4) == reqWin })
607+
608+ // SelectionClear aborts a running transfer and refuses further requests.
609+ w2 := r.serve(t, r.utf8)
610+ _, err = w2.Write(pattern(chunk))
611+ require.NoError(t, err)
612+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outActive })
613+ r.p.selectionClear(r.x.selWin, r.clip)
614+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].state == outFree })
615+ r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.utf8, r.data)
616+ require.Equal(t, r.data, sentNotify(t, r), "INCR announcement of the aborted transfer")
617+ require.Zero(t, sentNotify(t, r), "request after the clear is refused")
618+}
619+
620+// Replacing the Wayland selection mid-transfer aborts it; nil releases X ownership.
621+func TestSelectionClearAndRace(t *testing.T) {
622+ r := ownedByWayland(t, "text/plain;charset=utf-8")
623+ src := newMockfdSource(t)
624+ src.EXPECT().Remove().Return().Maybe()
625+ r.x.addFD = func(_ int, _, _ func()) (fdSource, error) { return src, nil }
626+ w := r.serve(t, r.utf8)
627+ _, err := w.Write(pattern(chunk))
628+ require.NoError(t, err)
629+ readUntil(t, r.x, func() bool { return r.x.sel.out[0].incr })
630+ r.x.WaylandSelection(SelClipboard, []string{"text/plain"})
631+ require.Equal(t, outFree, r.x.sel.out[0].state)
632+ r.x.WaylandSelection(SelClipboard, nil) // protected or cleared
633+ r.x.flush()
634+ req, _ := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == opSetSelectionOwner && q.u32(4) == 0 })
635+ require.Equal(t, r.clip, req.u32(8), "ownership released")
636+ require.False(t, r.x.sel.st[SelClipboard].ours)
637+}
638+
639+func TestSelectionCopyAllocationsWaylandToX(t *testing.T) {
640+ r := ownedByWayland(t, "text/plain;charset=utf-8")
641+ x := r.x
642+ sinkPeer(t, x, r.p)
643+ x.after = func(time.Duration, func()) func() { return func() {} }
644+ x.sel.timerOn = true
645+ data := pattern(4096)
646+ var ev xturbo.Event
647+ ev.Kind = xproto.KindSelectionRequest
648+ binary.LittleEndian.PutUint32(ev.Raw[8:], x.selWin)
649+ binary.LittleEndian.PutUint32(ev.Raw[12:], reqWin)
650+ binary.LittleEndian.PutUint32(ev.Raw[16:], r.clip)
651+ binary.LittleEndian.PutUint32(ev.Raw[20:], r.utf8)
652+ binary.LittleEndian.PutUint32(ev.Raw[24:], r.data)
653+ fds := make([]int, 2)
654+ round := func() {
655+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
656+ t.Fatal("pipe")
657+ }
658+ if _, err := unix.Write(fds[1], data); err != nil {
659+ t.Fatal("write")
660+ }
661+ unix.Close(fds[1])
662+ x.host.(*mockhost).ExpectedCalls = nil
663+ x.onSelectionRequest(&ev)
664+ x.posted = false
665+ x.flush()
666+ }
667+ _ = round
668+ // The host mock's matching is not allocation free, so the host call is replaced.
669+ x.sel.st[SelClipboard].wlMime = [len(textMimes)]string{"text/plain;charset=utf-8"}
670+ allocs := testing.AllocsPerRun(100, func() {
671+ if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil {
672+ t.Fatal("pipe")
673+ }
674+ if _, err := unix.Write(fds[1], data); err != nil {
675+ t.Fatal("write")
676+ }
677+ unix.Close(fds[1])
678+ x.startOut(0, SelClipboard, reqWin, r.data, r.utf8, 0, fds[0])
679+ x.posted = false
680+ x.flush()
681+ })
682+ // Pipe2 itself does not allocate; the measured path is read + ChangeProperty + SendEvent.
683+ require.Zero(t, allocs)
684+ require.Equal(t, outFree, x.sel.out[0].state)
685+}
A internal/adapters/wayland/xwm/selection_xsettings.go +205 -0
@@ -0,0 +1,205 @@
1+package xwm
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+
7+ "bnema.dev/xturbo"
8+ "bnema.dev/xturbo/xproto"
9+)
10+
11+// XSETTINGS 0.5 manager (selection _XSETTINGS_S0). The window is the XWM's
12+// own, independent of any selection bridge window.
13+
14+const (
15+ xsBufSize = 1024
16+ xsMaxString = 256 // longest theme name published
17+ xsInt = 0
18+ xsString = 1
19+)
20+
21+// xsettings is the manager state. Settings are fixed, sorted by name.
22+type xsettings struct {
23+ win uint32
24+ active bool // we own _XSETTINGS_S0
25+ taken bool // another manager owns it (boot check)
26+ ownerNo uint32
27+ argb uint32 // ARGB32 picture format, 0 unknown
28+
29+ serial uint32
30+ // Last published values and the serial of their last change.
31+ dpi int32
32+ theme string
33+ size int32
34+ dpiSerial uint32
35+ themeSerial uint32
36+ sizeSerial uint32
37+
38+ buf [xsBufSize]byte
39+}
40+
41+// xsettingsDPI is Xft/DPI*1024 for a scale: 96 dpi at scale 1, rounded to an
42+// integer dpi.
43+func xsettingsDPI(scale float64) int32 {
44+ if scale <= 0 {
45+ scale = 1
46+ }
47+ return int32(math.Round(96*scale)) * 1024
48+}
49+
50+func (x *XWM) cursorThemeName() string {
51+ t := x.cfg.CursorTheme
52+ if t == "" {
53+ t = "default"
54+ }
55+ if len(t) > xsMaxString {
56+ t = t[:xsMaxString]
57+ }
58+ return t
59+}
60+
61+func (x *XWM) cursorThemeSize() int32 {
62+ if s := x.cfg.CursorSize; s >= 8 && s <= 256 {
63+ return int32(s)
64+ }
65+ return 24
66+}
67+
68+// bootSettings runs at the end of boot: the XSETTINGS manager and the root
69+// cursor. Failures are never fatal.
70+func (x *XWM) bootSettings() error {
71+ if err := x.bootXSettings(); err != nil {
72+ return err
73+ }
74+ return x.bootRootCursor()
75+}
76+
77+func (x *XWM) bootXSettings() error {
78+ if !x.cfg.XSettings {
79+ return nil
80+ }
81+ conn, b := x.conn, x.conn.Buf()
82+ win, err := conn.NewXID()
83+ if err != nil {
84+ return err
85+ }
86+ x.xs.win = win
87+ var wv xproto.CreateWindowValues
88+ wv.SetEventMask(0)
89+ xproto.CreateWindow(b, 0, xproto.Window(win), xproto.Window(x.root), -1, -1, 1, 1, 0, xproto.WindowClassInputOnly, 0, &wv)
90+ x.expect(xproto.GetSelectionOwner(b, xproto.Atom(x.atoms[atomXSettingsS0])), kindSelOwner, 0, 0)
91+ if err := conn.Sync(&x.h); err != nil {
92+ return err
93+ }
94+ if x.bootErr != nil {
95+ return x.bootErr
96+ }
97+ if x.xs.taken {
98+ x.log.Warn().Uint32("owner", x.xs.ownerNo).Msg("another XSETTINGS manager runs, not taking _XSETTINGS_S0")
99+ xproto.DestroyWindow(b, xproto.Window(win))
100+ x.xs.win = 0
101+ return nil
102+ }
103+ x.publishXSettings(true)
104+ sel := x.atoms[atomXSettingsS0]
105+ xproto.SetSelectionOwner(b, xproto.Window(win), xproto.Atom(sel), 0)
106+ // ICCCM §2.8: announce the new manager to the root.
107+ var ev [32]byte
108+ ev[0], ev[1] = 33, 32 // ClientMessage, format 32
109+ binary.LittleEndian.PutUint32(ev[4:], x.root)
110+ binary.LittleEndian.PutUint32(ev[8:], x.atoms[atomManager])
111+ binary.LittleEndian.PutUint32(ev[16:], sel)
112+ binary.LittleEndian.PutUint32(ev[20:], win)
113+ xproto.SendEvent(b, false, xproto.Window(x.root), xproto.EventMaskStructureNotify, &ev)
114+ x.xs.active = true
115+ return nil
116+}
117+
118+// onSelOwnerReply: the boot GetSelectionOwner of _XSETTINGS_S0.
119+func (x *XWM) onSelOwnerReply(r *xturbo.Reply) {
120+ if v, ok := xproto.ParseGetSelectionOwnerReply(r.Raw); ok && v.Owner() != 0 && uint32(v.Owner()) != x.xs.win {
121+ x.xs.taken, x.xs.ownerNo = true, uint32(v.Owner())
122+ }
123+}
124+
125+// onXSettingsClear: another client took _XSETTINGS_S0.
126+func (x *XWM) onXSettingsClear(ev *xturbo.Event) {
127+ e := xproto.SelectionClearEvent(xproto.SelectionClearOf(ev))
128+ if x.xs.active && uint32(e.Owner()) == x.xs.win && uint32(e.Selection()) == x.atoms[atomXSettingsS0] {
129+ x.xs.active = false
130+ x.log.Warn().Msg("XSETTINGS manager replaced by another client, settings no longer updated")
131+ }
132+}
133+
134+// refreshXSettings re-evaluates the DPI after a layout change.
135+func (x *XWM) refreshXSettings() {
136+ if !x.xs.active {
137+ return
138+ }
139+ if x.publishXSettings(false) {
140+ x.wake()
141+ }
142+}
143+
144+// publishXSettings encodes and writes the property when a value changed
145+// (force: the first time). It reports whether it wrote. Unchanged values cost
146+// no allocation and no request.
147+func (x *XWM) publishXSettings(force bool) bool {
148+ s := &x.xs
149+ dpi := xsettingsDPI(x.space.Scale())
150+ theme, size := x.cursorThemeName(), x.cursorThemeSize()
151+ if !force && s.serial != 0 && dpi == s.dpi && theme == s.theme && size == s.size {
152+ return false
153+ }
154+ next := s.serial + 1 // wraps
155+ if force || s.serial == 0 || dpi != s.dpi {
156+ s.dpiSerial = next
157+ }
158+ if force || s.serial == 0 || theme != s.theme {
159+ s.themeSerial = next
160+ }
161+ if force || s.serial == 0 || size != s.size {
162+ s.sizeSerial = next
163+ }
164+ s.serial, s.dpi, s.theme, s.size = next, dpi, theme, size
165+ n := s.encode()
166+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(s.win),
167+ xproto.Atom(x.atoms[atomXSettingsSettings]), xproto.Atom(x.atoms[atomXSettingsSettings]), 8, uint32(n), s.buf[:n])
168+ return true
169+}
170+
171+func pad4(n int) int { return (n + 3) &^ 3 }
172+
173+// encode writes the settings, sorted by name, into s.buf.
174+func (s *xsettings) encode() int {
175+ le := binary.LittleEndian
176+ b := s.buf[:]
177+ clear(b[:16])
178+ b[0] = 0 // LSBFirst
179+ le.PutUint32(b[4:], s.serial)
180+ le.PutUint32(b[8:], 3)
181+ n := 12
182+ putName := func(typ byte, name string, serial uint32) {
183+ b[n], b[n+1] = typ, 0
184+ le.PutUint16(b[n+2:], uint16(len(name)))
185+ n += 4
186+ copy(b[n:], name)
187+ clear(b[n+len(name) : n+pad4(len(name))])
188+ n += pad4(len(name))
189+ le.PutUint32(b[n:], serial)
190+ n += 4
191+ }
192+ putName(xsString, "Gtk/CursorThemeName", s.themeSerial)
193+ le.PutUint32(b[n:], uint32(len(s.theme)))
194+ n += 4
195+ copy(b[n:], s.theme)
196+ clear(b[n+len(s.theme) : n+pad4(len(s.theme))])
197+ n += pad4(len(s.theme))
198+ putName(xsInt, "Gtk/CursorThemeSize", s.sizeSerial)
199+ le.PutUint32(b[n:], uint32(s.size))
200+ n += 4
201+ putName(xsInt, "Xft/DPI", s.dpiSerial)
202+ le.PutUint32(b[n:], uint32(s.dpi))
203+ n += 4
204+ return n
205+}
A internal/adapters/wayland/xwm/space.go +158 -0
@@ -0,0 +1,158 @@
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+ scale float64 // scale of the first output; not part of the change test
22+}
23+
24+// Origin is the layout point that is X (0,0): the top-left of the layout
25+// extents.
26+func (s *Space) Origin() (ox, oy int) { return s.ox, s.oy }
27+
28+// Dropped is how many outputs of the last Set did not fit.
29+func (s *Space) Dropped() int { return s.dropped }
30+
31+// Set recomputes the mapping and reports whether it changed.
32+func (s *Space) Set(l ports.Layout) bool {
33+ var next Space
34+ n := len(l)
35+ if n > maxRegions {
36+ next.dropped = n - maxRegions
37+ n = maxRegions
38+ }
39+ next.n = n
40+ var maxX, maxY int
41+ for i := 0; i < n; i++ {
42+ o := l[i]
43+ if i == 0 || o.X < next.ox {
44+ next.ox = o.X
45+ }
46+ if i == 0 || o.Y < next.oy {
47+ next.oy = o.Y
48+ }
49+ if i == 0 || o.X+o.Width > maxX {
50+ maxX = o.X + o.Width
51+ }
52+ if i == 0 || o.Y+o.Height > maxY {
53+ maxY = o.Y + o.Height
54+ }
55+ }
56+ for i := 0; i < n; i++ {
57+ o := l[i]
58+ next.regions[i] = Region{
59+ Layout: ports.Rect{X: o.X, Y: o.Y, W: o.Width, H: o.Height},
60+ X: ports.Rect{X: o.X - next.ox, Y: o.Y - next.oy, W: o.Width, H: o.Height},
61+ }
62+ }
63+ next.w, next.h = maxX-next.ox, maxY-next.oy
64+ if n > 0 {
65+ next.scale = l[0].Scale
66+ }
67+ if next.regions == s.regions && next.n == s.n && next.ox == s.ox && next.oy == s.oy &&
68+ next.w == s.w && next.h == s.h {
69+ s.dropped, s.scale = next.dropped, next.scale
70+ return false
71+ }
72+ *s = next
73+ return true
74+}
75+
76+// Scale is the scale of the first output of the layout, 1 when unset.
77+func (s *Space) Scale() float64 {
78+ if s.scale <= 0 {
79+ return 1
80+ }
81+ return s.scale
82+}
83+
84+// Size is the X screen size.
85+func (s *Space) Size() (w, h int) { return s.w, s.h }
86+
87+// nearest returns the index of the region containing the layout point, or
88+// the closest one by squared distance when it is in a hole. -1 when empty.
89+func (s *Space) nearest(lx, ly int) int {
90+ best, bestD := -1, 0
91+ for i := 0; i < s.n; i++ {
92+ r := s.regions[i].Layout
93+ dx := axisGap(lx, r.X, r.X+r.W)
94+ dy := axisGap(ly, r.Y, r.Y+r.H)
95+ d := dx*dx + dy*dy
96+ if best < 0 || d < bestD {
97+ best, bestD = i, d
98+ }
99+ }
100+ return best
101+}
102+
103+// nearestX is nearest in X space.
104+func (s *Space) nearestX(x, y int) int {
105+ best, bestD := -1, 0
106+ for i := 0; i < s.n; i++ {
107+ r := s.regions[i].X
108+ dx := axisGap(x, r.X, r.X+r.W)
109+ dy := axisGap(y, r.Y, r.Y+r.H)
110+ d := dx*dx + dy*dy
111+ if best < 0 || d < bestD {
112+ best, bestD = i, d
113+ }
114+ }
115+ return best
116+}
117+
118+// axisGap is the distance from v to [lo, hi): 0 inside.
119+func axisGap(v, lo, hi int) int {
120+ if v < lo {
121+ return lo - v
122+ }
123+ if v >= hi {
124+ return v - hi + 1
125+ }
126+ return 0
127+}
128+
129+// WholeOutput reports whether the X rect is exactly one output's, and that
130+// output's layout origin.
131+func (s *Space) WholeOutput(x, y, w, h int) (lx, ly int, ok bool) {
132+ for i := 0; i < s.n; i++ {
133+ if r := s.regions[i]; r.X.X == x && r.X.Y == y && r.X.W == w && r.X.H == h {
134+ return r.Layout.X, r.Layout.Y, true
135+ }
136+ }
137+ return 0, 0, false
138+}
139+
140+// ToX converts a layout point to X, through the nearest region.
141+func (s *Space) ToX(lx, ly int) (x, y int) {
142+ i := s.nearest(lx, ly)
143+ if i < 0 {
144+ return lx, ly
145+ }
146+ r := &s.regions[i]
147+ return r.X.X + lx - r.Layout.X, r.X.Y + ly - r.Layout.Y
148+}
149+
150+// ToLayout converts an X point to layout coordinates.
151+func (s *Space) ToLayout(x, y int) (lx, ly int) {
152+ i := s.nearestX(x, y)
153+ if i < 0 {
154+ return x, y
155+ }
156+ r := &s.regions[i]
157+ return r.Layout.X + x - r.X.X, r.Layout.Y + y - r.X.Y
158+}
A internal/adapters/wayland/xwm/space_test.go +110 -0
@@ -0,0 +1,110 @@
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+}
95+
96+func TestSpaceWholeOutputMatchesPlace(t *testing.T) {
97+ var s Space
98+ s.Set(ports.Layout{out(-640, 0, 640, 480), out(0, 0, 1920, 1080)})
99+ lx, ly, ok := s.WholeOutput(0, 0, 640, 480)
100+ if !ok || lx != -640 || ly != 0 {
101+ t.Fatalf("WholeOutput = %d,%d,%v", lx, ly, ok)
102+ }
103+ // Placing the window at that origin lands on the same X point.
104+ if x, y := s.ToX(lx, ly); x != 0 || y != 0 {
105+ t.Fatalf("ToX = %d,%d", x, y)
106+ }
107+ if _, _, ok := s.WholeOutput(0, 0, 640, 481); ok {
108+ t.Fatal("partial rect matched")
109+ }
110+}
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 +242 -0
@@ -0,0 +1,242 @@
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+ bypass uint8 // _NET_WM_BYPASS_COMPOSITOR, 0..2
59+ sized bool // the client asked for its size before Ready
60+
61+ // pairing
62+ serial uint64
63+ surface uint64
64+
65+ // layout position of a managed window, from Place
66+ lx, ly int32
67+ placed bool
68+
69+ // unmanaged (override-redirect) windows
70+ noInput bool // tooltip, DND, notification: never takes the pointer
71+ ovPaired bool // OverrideMapped sent, OverrideGone owed
72+ ovQueued bool // slot is in XWM.ovDirty
73+ ov ovRect // layout rect last sent to the host
74+
75+ // configure
76+ pend, sent pending
77+ stateSent uint8 // _NET_WM_STATE bits last written
78+}
79+
80+// table is a slab of records with an open-addressing index by X id. No Go map.
81+type table struct {
82+ recs []record // slab, len == cap, power of two
83+ free []uint32 // free slot indices (LIFO)
84+ index []uint32 // slot+1, 0 empty, indexGone tombstone; len == 2*len(recs)
85+ shift uint8
86+ n int
87+ tombs int
88+}
89+
90+func newTable(size int) *table {
91+ t := &table{}
92+ t.resize(size)
93+ return t
94+}
95+
96+// resize reallocates the slab, free list and index for size records and
97+// reindexes the live ones.
98+func (t *table) resize(size int) {
99+ old := t.recs
100+ t.recs = make([]record, size)
101+ copy(t.recs, old)
102+ t.free = make([]uint32, 0, size)
103+ for i := min(size, maxSlots) - 1; i >= 0; i-- {
104+ if i >= len(old) || old[i].kind == kindFree {
105+ t.free = append(t.free, uint32(i))
106+ }
107+ }
108+ t.index = make([]uint32, 2*size)
109+ t.shift = uint8(32 - bits.TrailingZeros(uint(len(t.index))))
110+ t.reindex()
111+}
112+
113+// reindex rebuilds the index from the live records, dropping tombstones.
114+// It does not allocate.
115+func (t *table) reindex() {
116+ clear(t.index)
117+ t.tombs = 0
118+ for i := range t.recs {
119+ if t.recs[i].kind != kindFree {
120+ t.indexAdd(t.recs[i].xid, uint32(i)+1)
121+ }
122+ }
123+}
124+
125+func (t *table) hash(xid uint32) uint32 { return (xid * 0x9E3779B1) >> t.shift }
126+
127+// indexAdd places slot for xid, which must be absent, in the first empty or
128+// tombstone position of its probe sequence.
129+func (t *table) indexAdd(xid, slot uint32) {
130+ mask := uint32(len(t.index) - 1)
131+ for i := t.hash(xid); ; i = (i + 1) & mask {
132+ switch t.index[i] {
133+ case 0:
134+ t.index[i] = slot
135+ return
136+ case indexGone:
137+ t.index[i] = slot
138+ t.tombs--
139+ return
140+ }
141+ }
142+}
143+
144+// grow doubles the table. It reports false at maxSlots.
145+func (t *table) grow() bool {
146+ if len(t.recs) >= 1<<16 {
147+ return false
148+ }
149+ t.resize(2 * len(t.recs))
150+ return len(t.free) > 0
151+}
152+
153+// insert returns the slot of xid, creating a managed record (generation
154+// bumped) when absent. It returns 0 when the table is full. Growth is the
155+// only allocation.
156+func (t *table) insert(xid uint32) Slot {
157+ if s := t.lookup(xid); s != 0 {
158+ return s
159+ }
160+ if len(t.free) == 0 && !t.grow() {
161+ return 0
162+ }
163+ i := t.free[len(t.free)-1]
164+ t.free = t.free[:len(t.free)-1]
165+ if t.n+t.tombs+1 > len(t.index)/4*3 { // keep an empty index cell for lookup to stop at
166+ t.reindex()
167+ }
168+ r := &t.recs[i]
169+ gen := r.gen + 1
170+ if gen == 0 {
171+ gen = 1
172+ }
173+ *r = record{xid: xid, kind: kindManaged, gen: gen, input: true}
174+ t.indexAdd(xid, i+1)
175+ t.n++
176+ return Slot(i + 1)
177+}
178+
179+// remove frees s. The record is zeroed except its generation.
180+func (t *table) remove(s Slot) {
181+ r := t.rec(s)
182+ if r == nil || r.kind == kindFree {
183+ return
184+ }
185+ mask := uint32(len(t.index) - 1)
186+ for i := t.hash(r.xid); ; i = (i + 1) & mask {
187+ v := t.index[i]
188+ if v == 0 {
189+ break
190+ }
191+ if v == uint32(s) {
192+ t.index[i] = indexGone
193+ t.tombs++
194+ break
195+ }
196+ }
197+ *r = record{gen: r.gen}
198+ t.free = append(t.free, uint32(s)-1)
199+ t.n--
200+ if t.n+t.tombs > len(t.index)/4*3 {
201+ t.reindex()
202+ }
203+}
204+
205+// lookup returns the slot of xid, or 0.
206+func (t *table) lookup(xid uint32) Slot {
207+ mask := uint32(len(t.index) - 1)
208+ for i := t.hash(xid); ; i = (i + 1) & mask {
209+ v := t.index[i]
210+ if v == 0 {
211+ return 0
212+ }
213+ if v != indexGone && t.recs[v-1].xid == xid {
214+ return Slot(v)
215+ }
216+ }
217+}
218+
219+// rec returns the record of s, or nil when s is out of range. The pointer is
220+// valid only until the next insert, host call or anything else that can grow
221+// the table or free s: never hold it across those, fetch it again with rec(s).
222+func (t *table) rec(s Slot) *record {
223+ if s == 0 || int(s) > len(t.recs) {
224+ return nil
225+ }
226+ return &t.recs[s-1]
227+}
228+
229+// tag is the Expect argument naming s: slot in the low 16 bits, generation
230+// in the high 16.
231+func (t *table) tag(s Slot) uint32 { return uint32(s) | uint32(t.recs[s-1].gen)<<16 }
232+
233+// live resolves an Expect tag. It returns nil when the slot was freed or
234+// reused since the request was sent.
235+func (t *table) live(tag uint32) (Slot, *record) {
236+ s := Slot(tag & 0xFFFF)
237+ r := t.rec(s)
238+ if r == nil || r.kind == kindFree || r.gen != uint16(tag>>16) {
239+ return 0, nil
240+ }
241+ return s, r
242+}
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+1c00740001005000f30300000000000000000000000000000000000000000000
2+0108750003000000ef0300000000000009000000000000000000000000000000
3+6e6574207469746c650000001c00750001005000f30300000000000000000000
4+0000000000000000000000000108760003000000ef0300000000000009000000
5+0000000000000000000000006e6574207469746c650000001c00760001005000
6+f303000000000000000000000000000000000000000000000108770003000000
7+ef03000000000000090000000000000000000000000000006e6574207469746c
8+650000001c00770001005000f303000000000000000000000000000000000000
9+000000000108780003000000ef03000000000000090000000000000000000000
10+000000006e6574207469746c650000001c00780001005000f303000000000000
11+000000000000000000000000000000000108790003000000ef03000000000000
12+090000000000000000000000000000006e6574207469746c650000001c007900
13+01005000f3030000000000000000000000000000000000000000000001087a00
14+03000000ef03000000000000090000000000000000000000000000006e657420
15+7469746c650000001c007a0001005000f3030000000000000000000000000000
16+000000000000000001087b0003000000ef030000000000000900000000000000
17+00000000000000006e6574207469746c650000001c007b0001005000f3030000
18+000000000000000000000000000000000000000001087c0003000000ef030000
19+00000000090000000000000000000000000000006e6574207469746c65000000
20+1c007c0001005000f30300000000000000000000000000000000000000000000
21+01087d0003000000ef0300000000000009000000000000000000000000000000
22+6e6574207469746c650000001c007d0001005000f30300000000000000000000
23+00000000000000000000000001087e0003000000ef0300000000000009000000
24+0000000000000000000000006e6574207469746c650000001c007e0001005000
25+f3030000000000000000000000000000000000000000000001087f0003000000
26+ef03000000000000090000000000000000000000000000006e6574207469746c
27+650000001c007f0001005000f303000000000000000000000000000000000000
28+000000000108800003000000ef03000000000000090000000000000000000000
29+000000006e6574207469746c650000001c00800001005000f303000000000000
30+000000000000000000000000000000000108810003000000ef03000000000000
31+090000000000000000000000000000006e6574207469746c650000001c008100
32+01005000f3030000000000000000000000000000000000000000000001088200
33+03000000ef03000000000000090000000000000000000000000000006e657420
34+7469746c650000001c00820001005000f3030000000000000000000000000000
35+00000000000000000108830003000000ef030000000000000900000000000000
36+00000000000000006e6574207469746c650000001c00830001005000f3030000
37+00000000000000000000000000000000000000000108840003000000ef030000
38+00000000090000000000000000000000000000006e6574207469746c65000000
39+1c00840001005000f30300000000000000000000000000000000000000000000
40+0108850003000000ef0300000000000009000000000000000000000000000000
41+6e6574207469746c650000001c00850001005000f30300000000000000000000
42+0000000000000000000000000108860003000000ef0300000000000009000000
43+0000000000000000000000006e6574207469746c650000001c00860001005000
44+f303000000000000000000000000000000000000000000000108870003000000
45+ef03000000000000090000000000000000000000000000006e6574207469746c
46+650000001c00870001005000f303000000000000000000000000000000000000
47+000000000108880003000000ef03000000000000090000000000000000000000
48+000000006e6574207469746c650000001c00880001005000f303000000000000
49+000000000000000000000000000000000108890003000000ef03000000000000
50+090000000000000000000000000000006e6574207469746c650000001c008900
51+01005000f3030000000000000000000000000000000000000000000001088a00
52+03000000ef03000000000000090000000000000000000000000000006e657420
53+7469746c650000001c008a0001005000f3030000000000000000000000000000
54+000000000000000001088b0003000000ef030000000000000900000000000000
55+00000000000000006e6574207469746c650000001c008b0001005000f3030000
56+000000000000000000000000000000000000000001088c0003000000ef030000
57+00000000090000000000000000000000000000006e6574207469746c65000000
58+1c008c0001005000f30300000000000000000000000000000000000000000000
59+01088d0003000000ef0300000000000009000000000000000000000000000000
60+6e6574207469746c650000001c008d0001005000f30300000000000000000000
61+00000000000000000000000001088e0003000000ef0300000000000009000000
62+0000000000000000000000006e6574207469746c650000001c008e0001005000
63+f3030000000000000000000000000000000000000000000001088f0003000000
64+ef03000000000000090000000000000000000000000000006e6574207469746c
65+650000001c008f0001005000f303000000000000000000000000000000000000
66+000000000108900003000000ef03000000000000090000000000000000000000
67+000000006e6574207469746c650000001c00900001005000f303000000000000
68+000000000000000000000000000000000108910003000000ef03000000000000
69+090000000000000000000000000000006e6574207469746c650000001c009100
70+01005000f3030000000000000000000000000000000000000000000001089200
71+03000000ef03000000000000090000000000000000000000000000006e657420
72+7469746c650000001c00920001005000f3030000000000000000000000000000
73+00000000000000000108930003000000ef030000000000000900000000000000
74+00000000000000006e6574207469746c650000001c00930001005000f3030000
75+00000000000000000000000000000000000000000108940003000000ef030000
76+00000000090000000000000000000000000000006e6574207469746c65000000
77+1c00940001005000f30300000000000000000000000000000000000000000000
78+0108950003000000ef0300000000000009000000000000000000000000000000
79+6e6574207469746c650000001c00950001005000f30300000000000000000000
80+0000000000000000000000000108960003000000ef0300000000000009000000
81+0000000000000000000000006e6574207469746c650000001c00960001005000
82+f303000000000000000000000000000000000000000000000108970003000000
83+ef03000000000000090000000000000000000000000000006e6574207469746c
84+650000001c00970001005000f303000000000000000000000000000000000000
85+000000000108980003000000ef03000000000000090000000000000000000000
86+000000006e6574207469746c650000001c00980001005000f303000000000000
87+000000000000000000000000000000000108990003000000ef03000000000000
88+090000000000000000000000000000006e6574207469746c650000001c009900
89+01005000f3030000000000000000000000000000000000000000000001089a00
90+03000000ef03000000000000090000000000000000000000000000006e657420
91+7469746c650000001c009a0001005000f3030000000000000000000000000000
92+000000000000000001089b0003000000ef030000000000000900000000000000
93+00000000000000006e6574207469746c650000001c009b0001005000f3030000
94+000000000000000000000000000000000000000001089c0003000000ef030000
95+00000000090000000000000000000000000000006e6574207469746c65000000
96+1c009c0001005000f30300000000000000000000000000000000000000000000
97+01089d0003000000ef0300000000000009000000000000000000000000000000
98+6e6574207469746c650000001c009d0001005000f30300000000000000000000
99+00000000000000000000000001089e0003000000ef0300000000000009000000
100+0000000000000000000000006e6574207469746c650000001c009e0001005000
101+f3030000000000000000000000000000000000000000000001089f0003000000
102+ef03000000000000090000000000000000000000000000006e6574207469746c
103+650000001c009f0001005000f303000000000000000000000000000000000000
104+000000000108a00003000000ef03000000000000090000000000000000000000
105+000000006e6574207469746c650000001c00a00001005000f303000000000000
106+000000000000000000000000000000000108a10003000000ef03000000000000
107+090000000000000000000000000000006e6574207469746c650000001c00a100
108+01005000f303000000000000000000000000000000000000000000000108a200
109+03000000ef03000000000000090000000000000000000000000000006e657420
110+7469746c650000001c00a20001005000f3030000000000000000000000000000
111+00000000000000000108a30003000000ef030000000000000900000000000000
112+00000000000000006e6574207469746c650000001c00a30001005000f3030000
113+00000000000000000000000000000000000000000108a40003000000ef030000
114+00000000090000000000000000000000000000006e6574207469746c65000000
115+1c00a40001005000f30300000000000000000000000000000000000000000000
116+0108a50003000000ef0300000000000009000000000000000000000000000000
117+6e6574207469746c650000001c00a50001005000f30300000000000000000000
118+0000000000000000000000000108a60003000000ef0300000000000009000000
119+0000000000000000000000006e6574207469746c650000001c00a60001005000
120+f303000000000000000000000000000000000000000000000108a70003000000
121+ef03000000000000090000000000000000000000000000006e6574207469746c
122+650000001c00a70001005000f303000000000000000000000000000000000000
123+000000000108a80003000000ef03000000000000090000000000000000000000
124+000000006e6574207469746c650000001c00a80001005000f303000000000000
125+000000000000000000000000000000000108a90003000000ef03000000000000
126+090000000000000000000000000000006e6574207469746c650000001c00a900
127+01005000f303000000000000000000000000000000000000000000000108aa00
128+03000000ef03000000000000090000000000000000000000000000006e657420
129+7469746c650000001c00aa0001005000f3030000000000000000000000000000
130+00000000000000000108ab0003000000ef030000000000000900000000000000
131+00000000000000006e6574207469746c650000001c00ab0001005000f3030000
132+00000000000000000000000000000000000000000108ac0003000000ef030000
133+00000000090000000000000000000000000000006e6574207469746c65000000
134+1c00ac0001005000f30300000000000000000000000000000000000000000000
135+0108ad0003000000ef0300000000000009000000000000000000000000000000
136+6e6574207469746c650000001c00ad0001005000f30300000000000000000000
137+0000000000000000000000000108ae0003000000ef0300000000000009000000
138+0000000000000000000000006e6574207469746c650000001c00ae0001005000
139+f303000000000000000000000000000000000000000000000108af0003000000
140+ef03000000000000090000000000000000000000000000006e6574207469746c
141+650000001c00af0001005000f303000000000000000000000000000000000000
142+000000000108b00003000000ef03000000000000090000000000000000000000
143+000000006e6574207469746c650000001c00b00001005000f303000000000000
144+000000000000000000000000000000000108b10003000000ef03000000000000
145+090000000000000000000000000000006e6574207469746c650000001c00b100
146+01005000f303000000000000000000000000000000000000000000000108b200
147+03000000ef03000000000000090000000000000000000000000000006e657420
148+7469746c650000001c00b20001005000f3030000000000000000000000000000
149+00000000000000000108b30003000000ef030000000000000900000000000000
150+00000000000000006e6574207469746c650000001c00b30001005000f3030000
151+00000000000000000000000000000000000000000108b40003000000ef030000
152+00000000090000000000000000000000000000006e6574207469746c65000000
153+1c00b40001005000f30300000000000000000000000000000000000000000000
154+0108b50003000000ef0300000000000009000000000000000000000000000000
155+6e6574207469746c650000001c00b50001005000f30300000000000000000000
156+0000000000000000000000000108b60003000000ef0300000000000009000000
157+0000000000000000000000006e6574207469746c650000001c00b60001005000
158+f303000000000000000000000000000000000000000000000108b70003000000
159+ef03000000000000090000000000000000000000000000006e6574207469746c
160+650000001c00b70001005000f303000000000000000000000000000000000000
161+000000000108b80003000000ef03000000000000090000000000000000000000
162+000000006e6574207469746c650000001c00b80001005000f303000000000000
163+000000000000000000000000000000000108b90003000000ef03000000000000
164+090000000000000000000000000000006e6574207469746c650000001c00b900
165+01005000f303000000000000000000000000000000000000000000000108ba00
166+03000000ef03000000000000090000000000000000000000000000006e657420
167+7469746c650000001c00ba0001005000f3030000000000000000000000000000
168+00000000000000000108bb0003000000ef030000000000000900000000000000
169+00000000000000006e6574207469746c650000001c00bb0001005000f3030000
170+00000000000000000000000000000000000000000108bc0003000000ef030000
171+00000000090000000000000000000000000000006e6574207469746c65000000
172+1c00bc0001005000f30300000000000000000000000000000000000000000000
173+0108bd0003000000ef0300000000000009000000000000000000000000000000
174+6e6574207469746c650000001c00bd0001005000f30300000000000000000000
175+0000000000000000000000000108be0003000000ef0300000000000009000000
176+0000000000000000000000006e6574207469746c650000001c00be0001005000
177+f303000000000000000000000000000000000000000000000108bf0003000000
178+ef03000000000000090000000000000000000000000000006e6574207469746c
179+650000001c00bf0001005000f303000000000000000000000000000000000000
180+000000000108c00003000000ef03000000000000090000000000000000000000
181+000000006e6574207469746c650000001c00c00001005000f303000000000000
182+000000000000000000000000000000000108c10003000000ef03000000000000
183+090000000000000000000000000000006e6574207469746c650000001c00c100
184+01005000f303000000000000000000000000000000000000000000000108c200
185+03000000ef03000000000000090000000000000000000000000000006e657420
186+7469746c650000001c00c20001005000f3030000000000000000000000000000
187+00000000000000000108c30003000000ef030000000000000900000000000000
188+00000000000000006e6574207469746c650000001c00c30001005000f3030000
189+00000000000000000000000000000000000000000108c40003000000ef030000
190+00000000090000000000000000000000000000006e6574207469746c65000000
191+1c00c40001005000f30300000000000000000000000000000000000000000000
192+0108c50003000000ef0300000000000009000000000000000000000000000000
193+6e6574207469746c650000001c00c50001005000f30300000000000000000000
194+0000000000000000000000000108c60003000000ef0300000000000009000000
195+0000000000000000000000006e6574207469746c650000001c00c60001005000
196+f303000000000000000000000000000000000000000000000108c70003000000
197+ef03000000000000090000000000000000000000000000006e6574207469746c
198+650000001c00c70001005000f303000000000000000000000000000000000000
199+000000000108c80003000000ef03000000000000090000000000000000000000
200+000000006e6574207469746c650000001c00c80001005000f303000000000000
201+000000000000000000000000000000000108c90003000000ef03000000000000
202+090000000000000000000000000000006e6574207469746c650000001c00c900
203+01005000f303000000000000000000000000000000000000000000000108ca00
204+03000000ef03000000000000090000000000000000000000000000006e657420
205+7469746c650000001c00ca0001005000f3030000000000000000000000000000
206+00000000000000000108cb0003000000ef030000000000000900000000000000
207+00000000000000006e6574207469746c650000001c00cb0001005000f3030000
208+00000000000000000000000000000000000000000108cc0003000000ef030000
209+00000000090000000000000000000000000000006e6574207469746c65000000
210+1c00cc0001005000f30300000000000000000000000000000000000000000000
211+0108cd0003000000ef0300000000000009000000000000000000000000000000
212+6e6574207469746c650000001c00cd0001005000f30300000000000000000000
213+0000000000000000000000000108ce0003000000ef0300000000000009000000
214+0000000000000000000000006e6574207469746c650000001c00ce0001005000
215+f303000000000000000000000000000000000000000000000108cf0003000000
216+ef03000000000000090000000000000000000000000000006e6574207469746c
217+650000001c00cf0001005000f303000000000000000000000000000000000000
218+000000000108d00003000000ef03000000000000090000000000000000000000
219+000000006e6574207469746c650000001c00d00001005000f303000000000000
220+000000000000000000000000000000000108d10003000000ef03000000000000
221+090000000000000000000000000000006e6574207469746c650000001c00d100
222+01005000f303000000000000000000000000000000000000000000000108d200
223+03000000ef03000000000000090000000000000000000000000000006e657420
224+7469746c650000001c00d20001005000f3030000000000000000000000000000
225+00000000000000000108d30003000000ef030000000000000900000000000000
226+00000000000000006e6574207469746c650000001c00d30001005000f3030000
227+00000000000000000000000000000000000000000108d40003000000ef030000
228+00000000090000000000000000000000000000006e6574207469746c65000000
229+1c00d40001005000f30300000000000000000000000000000000000000000000
230+0108d50003000000ef0300000000000009000000000000000000000000000000
231+6e6574207469746c650000001c00d50001005000f30300000000000000000000
232+0000000000000000000000000108d60003000000ef0300000000000009000000
233+0000000000000000000000006e6574207469746c650000001c00d60001005000
234+f303000000000000000000000000000000000000000000000108d70003000000
235+ef03000000000000090000000000000000000000000000006e6574207469746c
236+650000001c00d70001005000f303000000000000000000000000000000000000
237+000000000108d80003000000ef03000000000000090000000000000000000000
238+000000006e6574207469746c650000001c00d80001005000f303000000000000
239+000000000000000000000000000000000108d90003000000ef03000000000000
240+090000000000000000000000000000006e6574207469746c650000001c00d900
241+01005000f303000000000000000000000000000000000000000000000108da00
242+03000000ef03000000000000090000000000000000000000000000006e657420
243+7469746c650000001c00da0001005000f3030000000000000000000000000000
244+00000000000000000108db0003000000ef030000000000000900000000000000
245+00000000000000006e6574207469746c650000001c00db0001005000f3030000
246+00000000000000000000000000000000000000000108dc0003000000ef030000
247+00000000090000000000000000000000000000006e6574207469746c65000000
248+1c00dc0001005000f30300000000000000000000000000000000000000000000
249+0108dd0003000000ef0300000000000009000000000000000000000000000000
250+6e6574207469746c650000001c00dd0001005000f30300000000000000000000
251+0000000000000000000000000108de0003000000ef0300000000000009000000
252+0000000000000000000000006e6574207469746c650000001c00de0001005000
253+f303000000000000000000000000000000000000000000000108df0003000000
254+ef03000000000000090000000000000000000000000000006e6574207469746c
255+650000001c00df0001005000f303000000000000000000000000000000000000
256+000000000108e00003000000ef03000000000000090000000000000000000000
257+000000006e6574207469746c650000001c00e00001005000f303000000000000
258+000000000000000000000000000000000108e10003000000ef03000000000000
259+090000000000000000000000000000006e6574207469746c650000001c00e100
260+01005000f303000000000000000000000000000000000000000000000108e200
261+03000000ef03000000000000090000000000000000000000000000006e657420
262+7469746c650000001c00e20001005000f3030000000000000000000000000000
263+00000000000000000108e30003000000ef030000000000000900000000000000
264+00000000000000006e6574207469746c650000001c00e30001005000f3030000
265+00000000000000000000000000000000000000000108e40003000000ef030000
266+00000000090000000000000000000000000000006e6574207469746c65000000
267+1c00e40001005000f30300000000000000000000000000000000000000000000
268+0108e50003000000ef0300000000000009000000000000000000000000000000
269+6e6574207469746c650000001c00e50001005000f30300000000000000000000
270+0000000000000000000000000108e60003000000ef0300000000000009000000
271+0000000000000000000000006e6574207469746c650000001c00e60001005000
272+f303000000000000000000000000000000000000000000000108e70003000000
273+ef03000000000000090000000000000000000000000000006e6574207469746c
274+650000001c00e70001005000f303000000000000000000000000000000000000
275+000000000108e80003000000ef03000000000000090000000000000000000000
276+000000006e6574207469746c650000001c00e80001005000f303000000000000
277+000000000000000000000000000000000108e90003000000ef03000000000000
278+090000000000000000000000000000006e6574207469746c650000001c00e900
279+01005000f303000000000000000000000000000000000000000000000108ea00
280+03000000ef03000000000000090000000000000000000000000000006e657420
281+7469746c650000001c00ea0001005000f3030000000000000000000000000000
282+00000000000000000108eb0003000000ef030000000000000900000000000000
283+00000000000000006e6574207469746c650000001c00eb0001005000f3030000
284+00000000000000000000000000000000000000000108ec0003000000ef030000
285+00000000090000000000000000000000000000006e6574207469746c65000000
286+1c00ec0001005000f30300000000000000000000000000000000000000000000
287+0108ed0003000000ef0300000000000009000000000000000000000000000000
288+6e6574207469746c650000001c00ed0001005000f30300000000000000000000
289+0000000000000000000000000108ee0003000000ef0300000000000009000000
290+0000000000000000000000006e6574207469746c650000001c00ee0001005000
291+f303000000000000000000000000000000000000000000000108ef0003000000
292+ef03000000000000090000000000000000000000000000006e6574207469746c
293+650000001c00ef0001005000f303000000000000000000000000000000000000
294+000000000108f00003000000ef03000000000000090000000000000000000000
295+000000006e6574207469746c650000001c00f00001005000f303000000000000
296+000000000000000000000000000000000108f10003000000ef03000000000000
297+090000000000000000000000000000006e6574207469746c650000001c00f100
298+01005000f303000000000000000000000000000000000000000000000108f200
299+03000000ef03000000000000090000000000000000000000000000006e657420
300+7469746c650000001c00f20001005000f3030000000000000000000000000000
301+00000000000000000108f30003000000ef030000000000000900000000000000
302+00000000000000006e6574207469746c650000001c00f30001005000f3030000
303+00000000000000000000000000000000000000000108f40003000000ef030000
304+00000000090000000000000000000000000000006e6574207469746c65000000
305+1c00f40001005000f30300000000000000000000000000000000000000000000
306+0108f50003000000ef0300000000000009000000000000000000000000000000
307+6e6574207469746c650000001c00f50001005000f30300000000000000000000
308+0000000000000000000000000108f60003000000ef0300000000000009000000
309+0000000000000000000000006e6574207469746c650000001c00f60001005000
310+f303000000000000000000000000000000000000000000000108f70003000000
311+ef03000000000000090000000000000000000000000000006e6574207469746c
312+650000001c00f70001005000f303000000000000000000000000000000000000
313+000000000108f80003000000ef03000000000000090000000000000000000000
314+000000006e6574207469746c650000001c00f80001005000f303000000000000
315+000000000000000000000000000000000108f90003000000ef03000000000000
316+090000000000000000000000000000006e6574207469746c650000001c00f900
317+01005000f303000000000000000000000000000000000000000000000108fa00
318+03000000ef03000000000000090000000000000000000000000000006e657420
319+7469746c650000001c00fa0001005000f3030000000000000000000000000000
320+00000000000000000108fb0003000000ef030000000000000900000000000000
321+00000000000000006e6574207469746c650000001c00fb0001005000f3030000
322+00000000000000000000000000000000000000000108fc0003000000ef030000
323+00000000090000000000000000000000000000006e6574207469746c65000000
324+1c00fc0001005000f30300000000000000000000000000000000000000000000
325+0108fd0003000000ef0300000000000009000000000000000000000000000000
326+6e6574207469746c650000001c00fd0001005000f30300000000000000000000
327+0000000000000000000000000108fe0003000000ef0300000000000009000000
328+0000000000000000000000006e6574207469746c650000001c00fe0001005000
329+f303000000000000000000000000000000000000000000000108ff0003000000
330+ef03000000000000090000000000000000000000000000006e6574207469746c
331+650000001c00ff0001005000f303000000000000000000000000000000000000
332+000000000108000103000000ef03000000000000090000000000000000000000
333+000000006e6574207469746c650000001c00000101005000f303000000000000
334+000000000000000000000000000000000108010103000000ef03000000000000
335+090000000000000000000000000000006e6574207469746c650000001c000101
336+01005000f3030000000000000000000000000000000000000000000001080201
337+03000000ef03000000000000090000000000000000000000000000006e657420
338+7469746c650000001c00020101005000f3030000000000000000000000000000
339+00000000000000000108030103000000ef030000000000000900000000000000
340+00000000000000006e6574207469746c650000001c00030101005000f3030000
341+00000000000000000000000000000000000000000108040103000000ef030000
342+00000000090000000000000000000000000000006e6574207469746c65000000
343+1c00040101005000f30300000000000000000000000000000000000000000000
344+0108050103000000ef0300000000000009000000000000000000000000000000
345+6e6574207469746c650000001c00050101005000f30300000000000000000000
346+0000000000000000000000000108060103000000ef0300000000000009000000
347+0000000000000000000000006e6574207469746c650000001c00060101005000
348+f303000000000000000000000000000000000000000000000108070103000000
349+ef03000000000000090000000000000000000000000000006e6574207469746c
350+650000001c00070101005000f303000000000000000000000000000000000000
351+000000000108080103000000ef03000000000000090000000000000000000000
352+000000006e6574207469746c650000001c00080101005000f303000000000000
353+000000000000000000000000000000000108090103000000ef03000000000000
354+090000000000000000000000000000006e6574207469746c650000001c000901
355+01005000f3030000000000000000000000000000000000000000000001080a01
356+03000000ef03000000000000090000000000000000000000000000006e657420
357+7469746c650000001c000a0101005000f3030000000000000000000000000000
358+000000000000000001080b0103000000ef030000000000000900000000000000
359+00000000000000006e6574207469746c650000001c000b0101005000f3030000
360+000000000000000000000000000000000000000001080c0103000000ef030000
361+00000000090000000000000000000000000000006e6574207469746c65000000
362+1c000c0101005000f30300000000000000000000000000000000000000000000
363+01080d0103000000ef0300000000000009000000000000000000000000000000
364+6e6574207469746c650000001c000d0101005000f30300000000000000000000
365+00000000000000000000000001080e0103000000ef0300000000000009000000
366+0000000000000000000000006e6574207469746c650000001c000e0101005000
367+f3030000000000000000000000000000000000000000000001080f0103000000
368+ef03000000000000090000000000000000000000000000006e6574207469746c
369+650000001c000f0101005000f303000000000000000000000000000000000000
370+000000000108100103000000ef03000000000000090000000000000000000000
371+000000006e6574207469746c650000001c00100101005000f303000000000000
372+000000000000000000000000000000000108110103000000ef03000000000000
373+090000000000000000000000000000006e6574207469746c650000001c001101
374+01005000f3030000000000000000000000000000000000000000000001081201
375+03000000ef03000000000000090000000000000000000000000000006e657420
376+7469746c650000001c00120101005000f3030000000000000000000000000000
377+00000000000000000108130103000000ef030000000000000900000000000000
378+00000000000000006e6574207469746c650000001c00130101005000f3030000
379+00000000000000000000000000000000000000000108140103000000ef030000
380+00000000090000000000000000000000000000006e6574207469746c65000000
381+1c00140101005000f30300000000000000000000000000000000000000000000
382+0108150103000000ef0300000000000009000000000000000000000000000000
383+6e6574207469746c650000001c00150101005000f30300000000000000000000
384+0000000000000000000000000108160103000000ef0300000000000009000000
385+0000000000000000000000006e6574207469746c650000001c00160101005000
386+f303000000000000000000000000000000000000000000000108170103000000
387+ef03000000000000090000000000000000000000000000006e6574207469746c
388+650000001c00170101005000f303000000000000000000000000000000000000
389+000000000108180103000000ef03000000000000090000000000000000000000
390+000000006e6574207469746c650000001c00180101005000f303000000000000
391+000000000000000000000000000000000108190103000000ef03000000000000
392+090000000000000000000000000000006e6574207469746c650000001c001901
393+01005000f3030000000000000000000000000000000000000000000001081a01
394+03000000ef03000000000000090000000000000000000000000000006e657420
395+7469746c650000001c001a0101005000f3030000000000000000000000000000
396+000000000000000001081b0103000000ef030000000000000900000000000000
397+00000000000000006e6574207469746c650000001c001b0101005000f3030000
398+000000000000000000000000000000000000000001081c0103000000ef030000
399+00000000090000000000000000000000000000006e6574207469746c65000000
400+1c001c0101005000f30300000000000000000000000000000000000000000000
401+01081d0103000000ef0300000000000009000000000000000000000000000000
402+6e6574207469746c650000001c001d0101005000f30300000000000000000000
403+00000000000000000000000001081e0103000000ef0300000000000009000000
404+0000000000000000000000006e6574207469746c650000001c001e0101005000
405+f3030000000000000000000000000000000000000000000001081f0103000000
406+ef03000000000000090000000000000000000000000000006e6574207469746c
407+650000001c001f0101005000f303000000000000000000000000000000000000
408+000000000108200103000000ef03000000000000090000000000000000000000
409+000000006e6574207469746c650000001c00200101005000f303000000000000
410+000000000000000000000000000000000108210103000000ef03000000000000
411+090000000000000000000000000000006e6574207469746c650000001c002101
412+01005000f3030000000000000000000000000000000000000000000001082201
413+03000000ef03000000000000090000000000000000000000000000006e657420
414+7469746c650000001c00220101005000f3030000000000000000000000000000
415+00000000000000000108230103000000ef030000000000000900000000000000
416+00000000000000006e6574207469746c650000001c00230101005000f3030000
417+00000000000000000000000000000000000000000108240103000000ef030000
418+00000000090000000000000000000000000000006e6574207469746c65000000
419+1c00240101005000f30300000000000000000000000000000000000000000000
420+0108250103000000ef0300000000000009000000000000000000000000000000
421+6e6574207469746c650000001c00250101005000f30300000000000000000000
422+0000000000000000000000000108260103000000ef0300000000000009000000
423+0000000000000000000000006e6574207469746c650000001c00260101005000
424+f303000000000000000000000000000000000000000000000108270103000000
425+ef03000000000000090000000000000000000000000000006e6574207469746c
426+650000001c00270101005000f303000000000000000000000000000000000000
427+000000000108280103000000ef03000000000000090000000000000000000000
428+000000006e6574207469746c650000001c00280101005000f303000000000000
429+000000000000000000000000000000000108290103000000ef03000000000000
430+090000000000000000000000000000006e6574207469746c650000001c002901
431+01005000f3030000000000000000000000000000000000000000000001082a01
432+03000000ef03000000000000090000000000000000000000000000006e657420
433+7469746c650000001c002a0101005000f3030000000000000000000000000000
434+000000000000000001082b0103000000ef030000000000000900000000000000
435+00000000000000006e6574207469746c650000001c002b0101005000f3030000
436+000000000000000000000000000000000000000001082c0103000000ef030000
437+00000000090000000000000000000000000000006e6574207469746c65000000
438+1c002c0101005000f30300000000000000000000000000000000000000000000
439+01082d0103000000ef0300000000000009000000000000000000000000000000
440+6e6574207469746c650000001c002d0101005000f30300000000000000000000
441+00000000000000000000000001082e0103000000ef0300000000000009000000
442+0000000000000000000000006e6574207469746c650000001c002e0101005000
443+f3030000000000000000000000000000000000000000000001082f0103000000
444+ef03000000000000090000000000000000000000000000006e6574207469746c
445+650000001c002f0101005000f303000000000000000000000000000000000000
446+000000000108300103000000ef03000000000000090000000000000000000000
447+000000006e6574207469746c650000001c00300101005000f303000000000000
448+000000000000000000000000000000000108310103000000ef03000000000000
449+090000000000000000000000000000006e6574207469746c650000001c003101
450+01005000f3030000000000000000000000000000000000000000000001083201
451+03000000ef03000000000000090000000000000000000000000000006e657420
452+7469746c650000001c00320101005000f3030000000000000000000000000000
453+00000000000000000108330103000000ef030000000000000900000000000000
454+00000000000000006e6574207469746c650000001c00330101005000f3030000
455+00000000000000000000000000000000000000000108340103000000ef030000
456+00000000090000000000000000000000000000006e6574207469746c65000000
457+1c00340101005000f30300000000000000000000000000000000000000000000
458+0108350103000000ef0300000000000009000000000000000000000000000000
459+6e6574207469746c650000001c00350101005000f30300000000000000000000
460+0000000000000000000000000108360103000000ef0300000000000009000000
461+0000000000000000000000006e6574207469746c650000001c00360101005000
462+f303000000000000000000000000000000000000000000000108370103000000
463+ef03000000000000090000000000000000000000000000006e6574207469746c
464+650000001c00370101005000f303000000000000000000000000000000000000
465+000000000108380103000000ef03000000000000090000000000000000000000
466+000000006e6574207469746c650000001c00380101005000f303000000000000
467+000000000000000000000000000000000108390103000000ef03000000000000
468+090000000000000000000000000000006e6574207469746c650000001c003901
469+01005000f3030000000000000000000000000000000000000000000001083a01
470+03000000ef03000000000000090000000000000000000000000000006e657420
471+7469746c650000001c003a0101005000f3030000000000000000000000000000
472+000000000000000001083b0103000000ef030000000000000900000000000000
473+00000000000000006e6574207469746c650000001c003b0101005000f3030000
474+000000000000000000000000000000000000000001083c0103000000ef030000
475+00000000090000000000000000000000000000006e6574207469746c65000000
476+1c003c0101005000f30300000000000000000000000000000000000000000000
477+01083d0103000000ef0300000000000009000000000000000000000000000000
478+6e6574207469746c650000001c003d0101005000f30300000000000000000000
479+00000000000000000000000001083e0103000000ef0300000000000009000000
480+0000000000000000000000006e6574207469746c650000001c003e0101005000
481+f3030000000000000000000000000000000000000000000001083f0103000000
482+ef03000000000000090000000000000000000000000000006e6574207469746c
483+650000001c003f0101005000f303000000000000000000000000000000000000
484+000000000108400103000000ef03000000000000090000000000000000000000
485+000000006e6574207469746c650000001c00400101005000f303000000000000
486+000000000000000000000000000000000108410103000000ef03000000000000
487+090000000000000000000000000000006e6574207469746c650000001c004101
488+01005000f3030000000000000000000000000000000000000000000001084201
489+03000000ef03000000000000090000000000000000000000000000006e657420
490+7469746c650000001c00420101005000f3030000000000000000000000000000
491+00000000000000000108430103000000ef030000000000000900000000000000
492+00000000000000006e6574207469746c650000001c00430101005000f3030000
493+00000000000000000000000000000000000000000108440103000000ef030000
494+00000000090000000000000000000000000000006e6574207469746c65000000
495+1c00440101005000f30300000000000000000000000000000000000000000000
496+0108450103000000ef0300000000000009000000000000000000000000000000
497+6e6574207469746c650000001c00450101005000f30300000000000000000000
498+0000000000000000000000000108460103000000ef0300000000000009000000
499+0000000000000000000000006e6574207469746c650000001c00460101005000
500+f303000000000000000000000000000000000000000000000108470103000000
501+ef03000000000000090000000000000000000000000000006e6574207469746c
502+650000001c00470101005000f303000000000000000000000000000000000000
503+000000000108480103000000ef03000000000000090000000000000000000000
504+000000006e6574207469746c650000001c00480101005000f303000000000000
505+000000000000000000000000000000000108490103000000ef03000000000000
506+090000000000000000000000000000006e6574207469746c650000001c004901
507+01005000f3030000000000000000000000000000000000000000000001084a01
508+03000000ef03000000000000090000000000000000000000000000006e657420
509+7469746c650000001c004a0101005000f3030000000000000000000000000000
510+000000000000000001084b0103000000ef030000000000000900000000000000
511+00000000000000006e6574207469746c650000001c004b0101005000f3030000
512+000000000000000000000000000000000000000001084c0103000000ef030000
513+00000000090000000000000000000000000000006e6574207469746c65000000
514+1c004c0101005000f30300000000000000000000000000000000000000000000
515+01084d0103000000ef0300000000000009000000000000000000000000000000
516+6e6574207469746c650000001c004d0101005000f30300000000000000000000
517+00000000000000000000000001084e0103000000ef0300000000000009000000
518+0000000000000000000000006e6574207469746c650000001c004e0101005000
519+f3030000000000000000000000000000000000000000000001084f0103000000
520+ef03000000000000090000000000000000000000000000006e6574207469746c
521+650000001c004f0101005000f303000000000000000000000000000000000000
522+000000000108500103000000ef03000000000000090000000000000000000000
523+000000006e6574207469746c650000001c00500101005000f303000000000000
524+000000000000000000000000000000000108510103000000ef03000000000000
525+090000000000000000000000000000006e6574207469746c650000001c005101
526+01005000f3030000000000000000000000000000000000000000000001085201
527+03000000ef03000000000000090000000000000000000000000000006e657420
528+7469746c650000001c00520101005000f3030000000000000000000000000000
529+00000000000000000108530103000000ef030000000000000900000000000000
530+00000000000000006e6574207469746c650000001c00530101005000f3030000
531+00000000000000000000000000000000000000000108540103000000ef030000
532+00000000090000000000000000000000000000006e6574207469746c65000000
533+1c00540101005000f30300000000000000000000000000000000000000000000
534+0108550103000000ef0300000000000009000000000000000000000000000000
535+6e6574207469746c650000001c00550101005000f30300000000000000000000
536+0000000000000000000000000108560103000000ef0300000000000009000000
537+0000000000000000000000006e6574207469746c650000001c00560101005000
538+f303000000000000000000000000000000000000000000000108570103000000
539+ef03000000000000090000000000000000000000000000006e6574207469746c
540+650000001c00570101005000f303000000000000000000000000000000000000
541+000000000108580103000000ef03000000000000090000000000000000000000
542+000000006e6574207469746c650000001c00580101005000f303000000000000
543+000000000000000000000000000000000108590103000000ef03000000000000
544+090000000000000000000000000000006e6574207469746c650000001c005901
545+01005000f3030000000000000000000000000000000000000000000001085a01
546+03000000ef03000000000000090000000000000000000000000000006e657420
547+7469746c650000001c005a0101005000f3030000000000000000000000000000
548+000000000000000001085b0103000000ef030000000000000900000000000000
549+00000000000000006e6574207469746c650000001c005b0101005000f3030000
550+000000000000000000000000000000000000000001085c0103000000ef030000
551+00000000090000000000000000000000000000006e6574207469746c65000000
552+1c005c0101005000f30300000000000000000000000000000000000000000000
553+01085d0103000000ef0300000000000009000000000000000000000000000000
554+6e6574207469746c650000001c005d0101005000f30300000000000000000000
555+00000000000000000000000001085e0103000000ef0300000000000009000000
556+0000000000000000000000006e6574207469746c650000001c005e0101005000
557+f3030000000000000000000000000000000000000000000001085f0103000000
558+ef03000000000000090000000000000000000000000000006e6574207469746c
559+650000001c005f0101005000f303000000000000000000000000000000000000
560+000000000108600103000000ef03000000000000090000000000000000000000
561+000000006e6574207469746c650000001c00600101005000f303000000000000
562+000000000000000000000000000000000108610103000000ef03000000000000
563+090000000000000000000000000000006e6574207469746c650000001c006101
564+01005000f3030000000000000000000000000000000000000000000001086201
565+03000000ef03000000000000090000000000000000000000000000006e657420
566+7469746c650000001c00620101005000f3030000000000000000000000000000
567+00000000000000000108630103000000ef030000000000000900000000000000
568+00000000000000006e6574207469746c650000001c00630101005000f3030000
569+00000000000000000000000000000000000000000108640103000000ef030000
570+00000000090000000000000000000000000000006e6574207469746c65000000
571+1c00640101005000f30300000000000000000000000000000000000000000000
572+0108650103000000ef0300000000000009000000000000000000000000000000
573+6e6574207469746c650000001c00650101005000f30300000000000000000000
574+0000000000000000000000000108660103000000ef0300000000000009000000
575+0000000000000000000000006e6574207469746c650000001c00660101005000
576+f303000000000000000000000000000000000000000000000108670103000000
577+ef03000000000000090000000000000000000000000000006e6574207469746c
578+650000001c00670101005000f303000000000000000000000000000000000000
579+000000000108680103000000ef03000000000000090000000000000000000000
580+000000006e6574207469746c650000001c00680101005000f303000000000000
581+000000000000000000000000000000000108690103000000ef03000000000000
582+090000000000000000000000000000006e6574207469746c650000001c006901
583+01005000f3030000000000000000000000000000000000000000000001086a01
584+03000000ef03000000000000090000000000000000000000000000006e657420
585+7469746c650000001c006a0101005000f3030000000000000000000000000000
586+000000000000000001086b0103000000ef030000000000000900000000000000
587+00000000000000006e6574207469746c650000001c006b0101005000f3030000
588+000000000000000000000000000000000000000001086c0103000000ef030000
589+00000000090000000000000000000000000000006e6574207469746c65000000
590+1c006c0101005000f30300000000000000000000000000000000000000000000
591+01086d0103000000ef0300000000000009000000000000000000000000000000
592+6e6574207469746c650000001c006d0101005000f30300000000000000000000
593+00000000000000000000000001086e0103000000ef0300000000000009000000
594+0000000000000000000000006e6574207469746c650000001c006e0101005000
595+f3030000000000000000000000000000000000000000000001086f0103000000
596+ef03000000000000090000000000000000000000000000006e6574207469746c
597+650000001c006f0101005000f303000000000000000000000000000000000000
598+000000000108700103000000ef03000000000000090000000000000000000000
599+000000006e6574207469746c650000001c00700101005000f303000000000000
600+000000000000000000000000000000000108710103000000ef03000000000000
601+090000000000000000000000000000006e6574207469746c650000001c007101
602+01005000f3030000000000000000000000000000000000000000000001087201
603+03000000ef03000000000000090000000000000000000000000000006e657420
604+7469746c650000001c00720101005000f3030000000000000000000000000000
605+00000000000000000108730103000000ef030000000000000900000000000000
606+00000000000000006e6574207469746c650000001c00730101005000f3030000
607+00000000000000000000000000000000000000000108740103000000ef030000
608+00000000090000000000000000000000000000006e6574207469746c65000000
609+1c00740101005000f30300000000000000000000000000000000000000000000
610+0108750103000000ef0300000000000009000000000000000000000000000000
611+6e6574207469746c650000001c00750101005000f30300000000000000000000
612+0000000000000000000000000108760103000000ef0300000000000009000000
613+0000000000000000000000006e6574207469746c650000001c00760101005000
614+f303000000000000000000000000000000000000000000000108770103000000
615+ef03000000000000090000000000000000000000000000006e6574207469746c
616+650000001c00770101005000f303000000000000000000000000000000000000
617+000000000108780103000000ef03000000000000090000000000000000000000
618+000000006e6574207469746c650000001c00780101005000f303000000000000
619+000000000000000000000000000000000108790103000000ef03000000000000
620+090000000000000000000000000000006e6574207469746c650000001c007901
621+01005000f3030000000000000000000000000000000000000000000001087a01
622+03000000ef03000000000000090000000000000000000000000000006e657420
623+7469746c650000001c007a0101005000f3030000000000000000000000000000
624+000000000000000001087b0103000000ef030000000000000900000000000000
625+00000000000000006e6574207469746c650000001c007b0101005000f3030000
626+000000000000000000000000000000000000000001087c0103000000ef030000
627+00000000090000000000000000000000000000006e6574207469746c65000000
628+1c007c0101005000f30300000000000000000000000000000000000000000000
629+01087d0103000000ef0300000000000009000000000000000000000000000000
630+6e6574207469746c650000001c007d0101005000f30300000000000000000000
631+00000000000000000000000001087e0103000000ef0300000000000009000000
632+0000000000000000000000006e6574207469746c650000001c007e0101005000
633+f3030000000000000000000000000000000000000000000001087f0103000000
634+ef03000000000000090000000000000000000000000000006e6574207469746c
635+650000001c007f0101005000f303000000000000000000000000000000000000
636+000000000108800103000000ef03000000000000090000000000000000000000
637+000000006e6574207469746c650000001c00800101005000f303000000000000
638+000000000000000000000000000000000108810103000000ef03000000000000
639+090000000000000000000000000000006e6574207469746c650000001c008101
640+01005000f3030000000000000000000000000000000000000000000001088201
641+03000000ef03000000000000090000000000000000000000000000006e657420
642+7469746c650000001c00820101005000f3030000000000000000000000000000
643+00000000000000000108830103000000ef030000000000000900000000000000
644+00000000000000006e6574207469746c650000001c00830101005000f3030000
645+00000000000000000000000000000000000000000108840103000000ef030000
646+00000000090000000000000000000000000000006e6574207469746c65000000
647+1c00840101005000f30300000000000000000000000000000000000000000000
648+0108850103000000ef0300000000000009000000000000000000000000000000
649+6e6574207469746c650000001c00850101005000f30300000000000000000000
650+0000000000000000000000000108860103000000ef0300000000000009000000
651+0000000000000000000000006e6574207469746c650000001c00860101005000
652+f303000000000000000000000000000000000000000000000108870103000000
653+ef03000000000000090000000000000000000000000000006e6574207469746c
654+650000001c00870101005000f303000000000000000000000000000000000000
655+000000000108880103000000ef03000000000000090000000000000000000000
656+000000006e6574207469746c650000001c00880101005000f303000000000000
657+000000000000000000000000000000000108890103000000ef03000000000000
658+090000000000000000000000000000006e6574207469746c650000001c008901
659+01005000f3030000000000000000000000000000000000000000000001088a01
660+03000000ef03000000000000090000000000000000000000000000006e657420
661+7469746c650000001c008a0101005000f3030000000000000000000000000000
662+000000000000000001088b0103000000ef030000000000000900000000000000
663+00000000000000006e6574207469746c650000001c008b0101005000f3030000
664+000000000000000000000000000000000000000001088c0103000000ef030000
665+00000000090000000000000000000000000000006e6574207469746c65000000
666+1c008c0101005000f30300000000000000000000000000000000000000000000
667+01088d0103000000ef0300000000000009000000000000000000000000000000
668+6e6574207469746c650000001c008d0101005000f30300000000000000000000
669+00000000000000000000000001088e0103000000ef0300000000000009000000
670+0000000000000000000000006e6574207469746c650000001c008e0101005000
671+f3030000000000000000000000000000000000000000000001088f0103000000
672+ef03000000000000090000000000000000000000000000006e6574207469746c
673+650000001c008f0101005000f303000000000000000000000000000000000000
674+000000000108900103000000ef03000000000000090000000000000000000000
675+000000006e6574207469746c650000001c00900101005000f303000000000000
676+000000000000000000000000000000000108910103000000ef03000000000000
677+090000000000000000000000000000006e6574207469746c650000001c009101
678+01005000f3030000000000000000000000000000000000000000000001089201
679+03000000ef03000000000000090000000000000000000000000000006e657420
680+7469746c650000001c00920101005000f3030000000000000000000000000000
681+00000000000000000108930103000000ef030000000000000900000000000000
682+00000000000000006e6574207469746c650000001c00930101005000f3030000
683+00000000000000000000000000000000000000000108940103000000ef030000
684+00000000090000000000000000000000000000006e6574207469746c65000000
685+1c00940101005000f30300000000000000000000000000000000000000000000
686+0108950103000000ef0300000000000009000000000000000000000000000000
687+6e6574207469746c650000001c00950101005000f30300000000000000000000
688+0000000000000000000000000108960103000000ef0300000000000009000000
689+0000000000000000000000006e6574207469746c650000001c00960101005000
690+f303000000000000000000000000000000000000000000000108970103000000
691+ef03000000000000090000000000000000000000000000006e6574207469746c
692+650000001c00970101005000f303000000000000000000000000000000000000
693+000000000108980103000000ef03000000000000090000000000000000000000
694+000000006e6574207469746c650000001c00980101005000f303000000000000
695+000000000000000000000000000000000108990103000000ef03000000000000
696+090000000000000000000000000000006e6574207469746c650000001c009901
697+01005000f3030000000000000000000000000000000000000000000001089a01
698+03000000ef03000000000000090000000000000000000000000000006e657420
699+7469746c650000001c009a0101005000f3030000000000000000000000000000
700+000000000000000001089b0103000000ef030000000000000900000000000000
701+00000000000000006e6574207469746c650000001c009b0101005000f3030000
702+000000000000000000000000000000000000000001089c0103000000ef030000
703+00000000090000000000000000000000000000006e6574207469746c65000000
704+1c009c0101005000f30300000000000000000000000000000000000000000000
705+01089d0103000000ef0300000000000009000000000000000000000000000000
706+6e6574207469746c650000001c009d0101005000f30300000000000000000000
707+00000000000000000000000001089e0103000000ef0300000000000009000000
708+0000000000000000000000006e6574207469746c650000001c009e0101005000
709+f3030000000000000000000000000000000000000000000001089f0103000000
710+ef03000000000000090000000000000000000000000000006e6574207469746c
711+650000001c009f0101005000f303000000000000000000000000000000000000
712+000000000108a00103000000ef03000000000000090000000000000000000000
713+000000006e6574207469746c650000001c00a00101005000f303000000000000
714+000000000000000000000000000000000108a10103000000ef03000000000000
715+090000000000000000000000000000006e6574207469746c650000001c00a101
716+01005000f303000000000000000000000000000000000000000000000108a201
717+03000000ef03000000000000090000000000000000000000000000006e657420
718+7469746c650000001c00a20101005000f3030000000000000000000000000000
719+00000000000000000108a30103000000ef030000000000000900000000000000
720+00000000000000006e6574207469746c650000001c00a30101005000f3030000
721+00000000000000000000000000000000000000000108a40103000000ef030000
722+00000000090000000000000000000000000000006e6574207469746c65000000
723+1c00a40101005000f30300000000000000000000000000000000000000000000
724+0108a50103000000ef0300000000000009000000000000000000000000000000
725+6e6574207469746c650000001c00a50101005000f30300000000000000000000
726+0000000000000000000000000108a60103000000ef0300000000000009000000
727+0000000000000000000000006e6574207469746c650000001c00a60101005000
728+f303000000000000000000000000000000000000000000000108a70103000000
729+ef03000000000000090000000000000000000000000000006e6574207469746c
730+650000001c00a70101005000f303000000000000000000000000000000000000
731+000000000108a80103000000ef03000000000000090000000000000000000000
732+000000006e6574207469746c650000001c00a80101005000f303000000000000
733+000000000000000000000000000000000108a90103000000ef03000000000000
734+090000000000000000000000000000006e6574207469746c650000001c00a901
735+01005000f303000000000000000000000000000000000000000000000108aa01
736+03000000ef03000000000000090000000000000000000000000000006e657420
737+7469746c650000001c00aa0101005000f3030000000000000000000000000000
738+00000000000000000108ab0103000000ef030000000000000900000000000000
739+00000000000000006e6574207469746c650000001c00ab0101005000f3030000
740+00000000000000000000000000000000000000000108ac0103000000ef030000
741+00000000090000000000000000000000000000006e6574207469746c65000000
742+1c00ac0101005000f30300000000000000000000000000000000000000000000
743+0108ad0103000000ef0300000000000009000000000000000000000000000000
744+6e6574207469746c650000001c00ad0101005000f30300000000000000000000
745+0000000000000000000000000108ae0103000000ef0300000000000009000000
746+0000000000000000000000006e6574207469746c650000001c00ae0101005000
747+f303000000000000000000000000000000000000000000000108af0103000000
748+ef03000000000000090000000000000000000000000000006e6574207469746c
749+650000001c00af0101005000f303000000000000000000000000000000000000
750+000000000108b00103000000ef03000000000000090000000000000000000000
751+000000006e6574207469746c650000001c00b00101005000f303000000000000
752+000000000000000000000000000000000108b10103000000ef03000000000000
753+090000000000000000000000000000006e6574207469746c650000001c00b101
754+01005000f303000000000000000000000000000000000000000000000108b201
755+03000000ef03000000000000090000000000000000000000000000006e657420
756+7469746c650000001c00b20101005000f3030000000000000000000000000000
757+00000000000000000108b30103000000ef030000000000000900000000000000
758+00000000000000006e6574207469746c650000001c00b30101005000f3030000
759+00000000000000000000000000000000000000000108b40103000000ef030000
760+00000000090000000000000000000000000000006e6574207469746c65000000
761+1c00b40101005000f30300000000000000000000000000000000000000000000
762+0108b50103000000ef0300000000000009000000000000000000000000000000
763+6e6574207469746c650000001c00b50101005000f30300000000000000000000
764+0000000000000000000000000108b60103000000ef0300000000000009000000
765+0000000000000000000000006e6574207469746c650000001c00b60101005000
766+f303000000000000000000000000000000000000000000000108b70103000000
767+ef03000000000000090000000000000000000000000000006e6574207469746c
768+650000001c00b70101005000f303000000000000000000000000000000000000
769+000000000108b80103000000ef03000000000000090000000000000000000000
770+000000006e6574207469746c650000001c00b80101005000f303000000000000
771+000000000000000000000000000000000108b90103000000ef03000000000000
772+090000000000000000000000000000006e6574207469746c650000001c00b901
773+01005000f303000000000000000000000000000000000000000000000108ba01
774+03000000ef03000000000000090000000000000000000000000000006e657420
775+7469746c650000001c00ba0101005000f3030000000000000000000000000000
776+00000000000000000108bb0103000000ef030000000000000900000000000000
777+00000000000000006e6574207469746c650000001c00bb0101005000f3030000
778+00000000000000000000000000000000000000000108bc0103000000ef030000
779+00000000090000000000000000000000000000006e6574207469746c65000000
780+1c00bc0101005000f30300000000000000000000000000000000000000000000
781+0108bd0103000000ef0300000000000009000000000000000000000000000000
782+6e6574207469746c650000001c00bd0101005000f30300000000000000000000
783+0000000000000000000000000108be0103000000ef0300000000000009000000
784+0000000000000000000000006e6574207469746c650000001c00be0101005000
785+f303000000000000000000000000000000000000000000000108bf0103000000
786+ef03000000000000090000000000000000000000000000006e6574207469746c
787+650000001c00bf0101005000f303000000000000000000000000000000000000
788+000000000108c00103000000ef03000000000000090000000000000000000000
789+000000006e6574207469746c650000001c00c00101005000f303000000000000
790+000000000000000000000000000000000108c10103000000ef03000000000000
791+090000000000000000000000000000006e6574207469746c650000001c00c101
792+01005000f303000000000000000000000000000000000000000000000108c201
793+03000000ef03000000000000090000000000000000000000000000006e657420
794+7469746c650000001c00c20101005000f3030000000000000000000000000000
795+00000000000000000108c30103000000ef030000000000000900000000000000
796+00000000000000006e6574207469746c650000001c00c30101005000f3030000
797+00000000000000000000000000000000000000000108c40103000000ef030000
798+00000000090000000000000000000000000000006e6574207469746c65000000
799+1c00c40101005000f30300000000000000000000000000000000000000000000
800+0108c50103000000ef0300000000000009000000000000000000000000000000
801+6e6574207469746c650000001c00c50101005000f30300000000000000000000
802+0000000000000000000000000108c60103000000ef0300000000000009000000
803+0000000000000000000000006e6574207469746c650000001c00c60101005000
804+f303000000000000000000000000000000000000000000000108c70103000000
805+ef03000000000000090000000000000000000000000000006e6574207469746c
806+650000001c00c70101005000f303000000000000000000000000000000000000
807+000000000108c80103000000ef03000000000000090000000000000000000000
808+000000006e6574207469746c650000001c00c80101005000f303000000000000
809+000000000000000000000000000000000108c90103000000ef03000000000000
810+090000000000000000000000000000006e6574207469746c650000001c00c901
811+01005000f303000000000000000000000000000000000000000000000108ca01
812+03000000ef03000000000000090000000000000000000000000000006e657420
813+7469746c650000001c00ca0101005000f3030000000000000000000000000000
814+00000000000000000108cb0103000000ef030000000000000900000000000000
815+00000000000000006e6574207469746c650000001c00cb0101005000f3030000
816+00000000000000000000000000000000000000000108cc0103000000ef030000
817+00000000090000000000000000000000000000006e6574207469746c65000000
818+1c00cc0101005000f30300000000000000000000000000000000000000000000
819+0108cd0103000000ef0300000000000009000000000000000000000000000000
820+6e6574207469746c650000001c00cd0101005000f30300000000000000000000
821+0000000000000000000000000108ce0103000000ef0300000000000009000000
822+0000000000000000000000006e6574207469746c650000001c00ce0101005000
823+f303000000000000000000000000000000000000000000000108cf0103000000
824+ef03000000000000090000000000000000000000000000006e6574207469746c
825+650000001c00cf0101005000f303000000000000000000000000000000000000
826+000000000108d00103000000ef03000000000000090000000000000000000000
827+000000006e6574207469746c650000001c00d00101005000f303000000000000
828+000000000000000000000000000000000108d10103000000ef03000000000000
829+090000000000000000000000000000006e6574207469746c650000001c00d101
830+01005000f303000000000000000000000000000000000000000000000108d201
831+03000000ef03000000000000090000000000000000000000000000006e657420
832+7469746c650000001c00d20101005000f3030000000000000000000000000000
833+00000000000000000108d30103000000ef030000000000000900000000000000
834+00000000000000006e6574207469746c650000001c00d30101005000f3030000
835+00000000000000000000000000000000000000000108d40103000000ef030000
836+00000000090000000000000000000000000000006e6574207469746c65000000
837+1c00d40101005000f30300000000000000000000000000000000000000000000
838+0108d50103000000ef0300000000000009000000000000000000000000000000
839+6e6574207469746c650000001c00d50101005000f30300000000000000000000
840+0000000000000000000000000108d60103000000ef0300000000000009000000
841+0000000000000000000000006e6574207469746c650000001c00d60101005000
842+f303000000000000000000000000000000000000000000000108d70103000000
843+ef03000000000000090000000000000000000000000000006e6574207469746c
844+650000001c00d70101005000f303000000000000000000000000000000000000
845+000000000108d80103000000ef03000000000000090000000000000000000000
846+000000006e6574207469746c650000001c00d80101005000f303000000000000
847+000000000000000000000000000000000108d90103000000ef03000000000000
848+090000000000000000000000000000006e6574207469746c650000001c00d901
849+01005000f303000000000000000000000000000000000000000000000108da01
850+03000000ef03000000000000090000000000000000000000000000006e657420
851+7469746c650000001c00da0101005000f3030000000000000000000000000000
852+00000000000000000108db0103000000ef030000000000000900000000000000
853+00000000000000006e6574207469746c650000001c00db0101005000f3030000
854+00000000000000000000000000000000000000000108dc0103000000ef030000
855+00000000090000000000000000000000000000006e6574207469746c65000000
856+1c00dc0101005000f30300000000000000000000000000000000000000000000
857+0108dd0103000000ef0300000000000009000000000000000000000000000000
858+6e6574207469746c650000001c00dd0101005000f30300000000000000000000
859+0000000000000000000000000108de0103000000ef0300000000000009000000
860+0000000000000000000000006e6574207469746c650000001c00de0101005000
861+f303000000000000000000000000000000000000000000000108df0103000000
862+ef03000000000000090000000000000000000000000000006e6574207469746c
863+650000001c00df0101005000f303000000000000000000000000000000000000
864+000000000108e00103000000ef03000000000000090000000000000000000000
865+000000006e6574207469746c650000001c00e00101005000f303000000000000
866+000000000000000000000000000000000108e10103000000ef03000000000000
867+090000000000000000000000000000006e6574207469746c650000001c00e101
868+01005000f303000000000000000000000000000000000000000000000108e201
869+03000000ef03000000000000090000000000000000000000000000006e657420
870+7469746c650000001c00e20101005000f3030000000000000000000000000000
871+00000000000000000108e30103000000ef030000000000000900000000000000
872+00000000000000006e6574207469746c650000001c00e30101005000f3030000
873+00000000000000000000000000000000000000000108e40103000000ef030000
874+00000000090000000000000000000000000000006e6574207469746c65000000
875+1c00e40101005000f30300000000000000000000000000000000000000000000
876+0108e50103000000ef0300000000000009000000000000000000000000000000
877+6e6574207469746c650000001c00e50101005000f30300000000000000000000
878+0000000000000000000000000108e60103000000ef0300000000000009000000
879+0000000000000000000000006e6574207469746c650000001c00e60101005000
880+f303000000000000000000000000000000000000000000000108e70103000000
881+ef03000000000000090000000000000000000000000000006e6574207469746c
882+650000001c00e70101005000f303000000000000000000000000000000000000
883+000000000108e80103000000ef03000000000000090000000000000000000000
884+000000006e6574207469746c650000001c00e80101005000f303000000000000
885+000000000000000000000000000000000108e90103000000ef03000000000000
886+090000000000000000000000000000006e6574207469746c650000001c00e901
887+01005000f303000000000000000000000000000000000000000000000108ea01
888+03000000ef03000000000000090000000000000000000000000000006e657420
889+7469746c650000001c00ea0101005000f3030000000000000000000000000000
890+00000000000000000108eb0103000000ef030000000000000900000000000000
891+00000000000000006e6574207469746c650000001c00eb0101005000f3030000
892+00000000000000000000000000000000000000000108ec0103000000ef030000
893+00000000090000000000000000000000000000006e6574207469746c65000000
894+1c00ec0101005000f30300000000000000000000000000000000000000000000
895+0108ed0103000000ef0300000000000009000000000000000000000000000000
896+6e6574207469746c650000001c00ed0101005000f30300000000000000000000
897+0000000000000000000000000108ee0103000000ef0300000000000009000000
898+0000000000000000000000006e6574207469746c650000001c00ee0101005000
899+f303000000000000000000000000000000000000000000000108ef0103000000
900+ef03000000000000090000000000000000000000000000006e6574207469746c
901+650000001c00ef0101005000f303000000000000000000000000000000000000
902+000000000108f00103000000ef03000000000000090000000000000000000000
903+000000006e6574207469746c650000001c00f00101005000f303000000000000
904+000000000000000000000000000000000108f10103000000ef03000000000000
905+090000000000000000000000000000006e6574207469746c650000001c00f101
906+01005000f303000000000000000000000000000000000000000000000108f201
907+03000000ef03000000000000090000000000000000000000000000006e657420
908+7469746c650000001c00f20101005000f3030000000000000000000000000000
909+00000000000000000108f30103000000ef030000000000000900000000000000
910+00000000000000006e6574207469746c650000001c00f30101005000f3030000
911+00000000000000000000000000000000000000000108f40103000000ef030000
912+00000000090000000000000000000000000000006e6574207469746c65000000
913+1c00f40101005000f30300000000000000000000000000000000000000000000
914+0108f50103000000ef0300000000000009000000000000000000000000000000
915+6e6574207469746c650000001c00f50101005000f30300000000000000000000
916+0000000000000000000000000108f60103000000ef0300000000000009000000
917+0000000000000000000000006e6574207469746c650000001c00f60101005000
918+f303000000000000000000000000000000000000000000000108f70103000000
919+ef03000000000000090000000000000000000000000000006e6574207469746c
920+650000001c00f70101005000f303000000000000000000000000000000000000
921+000000000108f80103000000ef03000000000000090000000000000000000000
922+000000006e6574207469746c650000001c00f80101005000f303000000000000
923+000000000000000000000000000000000108f90103000000ef03000000000000
924+090000000000000000000000000000006e6574207469746c650000001c00f901
925+01005000f303000000000000000000000000000000000000000000000108fa01
926+03000000ef03000000000000090000000000000000000000000000006e657420
927+7469746c650000001c00fa0101005000f3030000000000000000000000000000
928+00000000000000000108fb0103000000ef030000000000000900000000000000
929+00000000000000006e6574207469746c650000001c00fb0101005000f3030000
930+00000000000000000000000000000000000000000108fc0103000000ef030000
931+00000000090000000000000000000000000000006e6574207469746c65000000
932+1c00fc0101005000f30300000000000000000000000000000000000000000000
933+0108fd0103000000ef0300000000000009000000000000000000000000000000
934+6e6574207469746c650000001c00fd0101005000f30300000000000000000000
935+0000000000000000000000000108fe0103000000ef0300000000000009000000
936+0000000000000000000000006e6574207469746c650000001c00fe0101005000
937+f303000000000000000000000000000000000000000000000108ff0103000000
938+ef03000000000000090000000000000000000000000000006e6574207469746c
939+650000001c00ff0101005000f303000000000000000000000000000000000000
940+000000000108000203000000ef03000000000000090000000000000000000000
941+000000006e6574207469746c650000001c00000201005000f303000000000000
942+000000000000000000000000000000000108010203000000ef03000000000000
943+090000000000000000000000000000006e6574207469746c650000001c000102
944+01005000f3030000000000000000000000000000000000000000000001080202
945+03000000ef03000000000000090000000000000000000000000000006e657420
946+7469746c650000001c00020201005000f3030000000000000000000000000000
947+00000000000000000108030203000000ef030000000000000900000000000000
948+00000000000000006e6574207469746c650000001c00030201005000f3030000
949+00000000000000000000000000000000000000000108040203000000ef030000
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 +631 -0
@@ -0,0 +1,631 @@
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+ opGetSelectionOwner = 23
30+ opSendEvent = 25
31+ opSetInputFocus = 42
32+ opGetInputFocus = 43
33+ opQueryExtension = 98
34+ opKillClient = 113
35+)
36+
37+const (
38+ peerRoot = 0x100
39+ peerXIDBase = 0x200000
40+ peerXIDMask = 0x1fffff
41+ peerMaxWords = 0x100000
42+)
43+
44+type xpeerExt struct{ major, firstEvent, firstError uint8 }
45+
46+// xpeerReq is one request the peer received.
47+type xpeerReq struct {
48+ seq uint16
49+ major uint8
50+ minor uint8
51+ raw []byte // whole request, header included
52+}
53+
54+func (r xpeerReq) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) }
55+
56+type xpeerProp struct {
57+ typ uint32
58+ format uint8
59+ data []byte
60+}
61+
62+type xpeerGeom struct {
63+ x, y int16
64+ w, h, border uint16
65+}
66+
67+// xpeer is a scripted X server speaking the real wire protocol over a
68+// socketpair: setup, extension queries, atoms, properties, geometry, window
69+// tree. It records every request. It is a protocol peer, not a test double of
70+// a project interface.
71+type xpeer struct {
72+ t *testing.T
73+ f *os.File
74+
75+ mu sync.Mutex
76+ seq uint16
77+ reqs []xpeerReq
78+ exts map[string]xpeerExt
79+ atoms map[string]uint32
80+ nextAtom uint32
81+ props map[uint32]map[uint32]xpeerProp
82+ geoms map[uint32]xpeerGeom
83+ attrs map[uint32][2]byte // window -> {overrideRedirect, mapState}
84+ children []uint32
85+ selOwner map[uint32]uint32 // GetSelectionOwner answers, by selection atom
86+ noARGB bool // RENDER has no ARGB32 format
87+ denyRoot bool // answer the root event-mask request with BadAccess
88+ pids map[uint32]uint32 // XRes QueryClientIds answers, by window
89+ bad map[uint32]bool // windows whose GetProperty/GetGeometry fail with BadWindow
90+ hold bool // keep replies until release
91+ held [][]byte
92+ tap []byte // everything written to the client, when recording
93+ record bool
94+
95+ gate atomic.Pointer[chan struct{}] // closed = reading allowed
96+ sbuf [4096]byte
97+ sink atomic.Bool // discard requests without recording or allocating
98+ done chan struct{}
99+}
100+
101+func fullPeerExts() map[string]xpeerExt {
102+ return map[string]xpeerExt{
103+ "BIG-REQUESTS": {133, 0, 0},
104+ "XC-MISC": {136, 0, 0},
105+ "Composite": {142, 0, 0},
106+ "XFIXES": {138, 87, 140},
107+ "X-Resource": {144, 0, 0},
108+ }
109+}
110+
111+// startXPeer returns the client end of a socketpair, to give to Boot, and the
112+// peer serving the other end.
113+func startXPeer(t *testing.T, exts map[string]xpeerExt) (int, *xpeer) {
114+ t.Helper()
115+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ p := &xpeer{
120+ t: t, f: os.NewFile(uintptr(fds[1]), "xpeer"),
121+ exts: exts, atoms: map[string]uint32{}, nextAtom: 1000,
122+ props: map[uint32]map[uint32]xpeerProp{}, geoms: map[uint32]xpeerGeom{}, attrs: map[uint32][2]byte{},
123+ pids: map[uint32]uint32{}, bad: map[uint32]bool{},
124+ done: make(chan struct{}),
125+ }
126+ open := make(chan struct{})
127+ close(open)
128+ p.gate.Store(&open)
129+ t.Cleanup(func() {
130+ p.resume() // a paused peer would never see the close
131+ p.f.Close()
132+ <-p.done
133+ })
134+ go p.serve()
135+ return fds[0], p
136+}
137+
138+func (p *xpeer) setupReply() []byte {
139+ vendor := []byte("xpeer\x00\x00\x00")
140+ b := make([]byte, 40+len(vendor)+40)
141+ b[0], b[2] = 1, 11
142+ binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4))
143+ binary.LittleEndian.PutUint32(b[12:], peerXIDBase)
144+ binary.LittleEndian.PutUint32(b[16:], peerXIDMask)
145+ binary.LittleEndian.PutUint16(b[24:], 5)
146+ b[28] = 1
147+ copy(b[40:], vendor)
148+ scr := b[40+len(vendor):]
149+ binary.LittleEndian.PutUint32(scr, peerRoot)
150+ binary.LittleEndian.PutUint32(scr[32:], 0x22) // root visual
151+ scr[38] = 24 // root depth
152+ return b
153+}
154+
155+func (p *xpeer) serve() {
156+ defer close(p.done)
157+ var hdr [12]byte
158+ if _, err := io.ReadFull(p.f, hdr[:]); err != nil {
159+ return
160+ }
161+ // Auth name and data are empty (NewConn sends none).
162+ if _, err := p.f.Write(p.setupReply()); err != nil {
163+ return
164+ }
165+ for {
166+ <-*p.gate.Load()
167+ if p.sink.Load() {
168+ if !p.discard() {
169+ return
170+ }
171+ continue
172+ }
173+ var h [4]byte
174+ if _, err := io.ReadFull(p.f, h[:]); err != nil {
175+ return
176+ }
177+ raw := h[:]
178+ n := int(binary.LittleEndian.Uint16(h[2:])) * 4
179+ if n == 0 { // BIG-REQUESTS header
180+ var l [4]byte
181+ if _, err := io.ReadFull(p.f, l[:]); err != nil {
182+ return
183+ }
184+ raw = append(raw, l[:]...)
185+ n = int(binary.LittleEndian.Uint32(l[:])) * 4
186+ }
187+ rest := make([]byte, n-len(raw))
188+ if _, err := io.ReadFull(p.f, rest); err != nil {
189+ return
190+ }
191+ raw = append(append([]byte(nil), raw...), rest...)
192+ p.mu.Lock()
193+ p.seq++
194+ r := xpeerReq{p.seq, h[0], h[1], raw}
195+ p.reqs = append(p.reqs, r)
196+ p.answer(r, raw[len(raw)-len(rest):])
197+ p.mu.Unlock()
198+ }
199+}
200+
201+// discard reads and drops one request without allocating. It reports false
202+// when the connection ended.
203+func (p *xpeer) discard() bool {
204+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
205+ return false
206+ }
207+ n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4
208+ left := n - 4
209+ if n == 0 {
210+ if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil {
211+ return false
212+ }
213+ left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8
214+ }
215+ for left > 0 {
216+ m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))])
217+ if err != nil {
218+ return false
219+ }
220+ left -= m
221+ }
222+ return true
223+}
224+
225+// write sends bytes to the client. The caller holds p.mu.
226+func (p *xpeer) write(b []byte) {
227+ if p.record {
228+ p.tap = append(p.tap, b...)
229+ }
230+ _, _ = p.f.Write(b)
231+}
232+
233+func (p *xpeer) reply(data uint8, body []byte) {
234+ words := (max(len(body), 24) - 24 + 3) / 4
235+ b := make([]byte, 32+4*words)
236+ b[0], b[1] = 1, data
237+ binary.LittleEndian.PutUint16(b[2:], p.seq)
238+ binary.LittleEndian.PutUint32(b[4:], uint32(words))
239+ copy(b[8:], body)
240+ if p.hold {
241+ p.held = append(p.held, b)
242+ return
243+ }
244+ p.write(b)
245+}
246+
247+// release sends the held replies, in request order as a real server does.
248+func (p *xpeer) release() {
249+ p.mu.Lock()
250+ defer p.mu.Unlock()
251+ p.hold = false
252+ for _, b := range p.held {
253+ p.write(b)
254+ }
255+ p.held = nil
256+}
257+
258+// holdReplies keeps the replies of the following requests back.
259+func (p *xpeer) holdReplies() {
260+ p.mu.Lock()
261+ p.hold = true
262+ p.mu.Unlock()
263+}
264+
265+func (p *xpeer) errorPacket(code, major uint8, bad uint32) {
266+ b := make([]byte, 32)
267+ b[1] = code
268+ binary.LittleEndian.PutUint16(b[2:], p.seq)
269+ binary.LittleEndian.PutUint32(b[4:], bad)
270+ b[10] = major
271+ p.write(b)
272+}
273+
274+func (p *xpeer) extName(major uint8) string {
275+ for name, e := range p.exts {
276+ if e.major == major {
277+ return name
278+ }
279+ }
280+ return ""
281+}
282+
283+func le32(v ...uint32) []byte {
284+ b := make([]byte, 0, 4*len(v))
285+ for _, x := range v {
286+ b = binary.LittleEndian.AppendUint32(b, x)
287+ }
288+ return b
289+}
290+
291+// answer runs with p.mu held.
292+func (p *xpeer) answer(r xpeerReq, body []byte) {
293+ switch {
294+ case r.major == opQueryExtension:
295+ e, ok := p.exts[string(body[4:4+binary.LittleEndian.Uint16(body)])]
296+ var present uint8
297+ if ok {
298+ present = 1
299+ }
300+ p.reply(0, []byte{present, e.major, e.firstEvent, e.firstError})
301+ case r.major == opInternAtom:
302+ name := string(body[4 : 4+binary.LittleEndian.Uint16(body)])
303+ a, ok := p.atoms[name]
304+ if !ok {
305+ p.nextAtom++
306+ a = p.nextAtom
307+ p.atoms[name] = a
308+ }
309+ p.reply(0, le32(a))
310+ case r.major == opGetSelectionOwner:
311+ p.reply(0, le32(p.selOwner[binary.LittleEndian.Uint32(body)]))
312+ case r.major == opGetInputFocus:
313+ p.reply(1, le32(0))
314+ case r.major == opQueryTree:
315+ b := make([]byte, 24+4*len(p.children))
316+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
317+ binary.LittleEndian.PutUint16(b[8:], uint16(len(p.children)))
318+ for i, c := range p.children {
319+ binary.LittleEndian.PutUint32(b[24+4*i:], c)
320+ }
321+ p.reply(0, b)
322+ case r.major == opGetWindowAttributes:
323+ a := p.attrs[binary.LittleEndian.Uint32(body)]
324+ b := make([]byte, 36) // reply length 3
325+ b[18] = a[1] // map_state is byte 26 of the reply
326+ b[19] = a[0] // override_redirect, byte 27
327+ p.reply(0, b)
328+ case r.major == opGetGeometry && p.bad[binary.LittleEndian.Uint32(body)]:
329+ p.errorPacket(9, r.major, binary.LittleEndian.Uint32(body)) // BadDrawable
330+ case r.major == opGetProperty && p.bad[binary.LittleEndian.Uint32(body)]:
331+ p.errorPacket(3, r.major, binary.LittleEndian.Uint32(body)) // BadWindow
332+ case r.major == opConfigureWindow:
333+ g := p.geoms[binary.LittleEndian.Uint32(body)]
334+ mask, vals := binary.LittleEndian.Uint16(body[4:]), body[8:]
335+ for bit, dst := 0, []any{&g.x, &g.y, &g.w, &g.h, &g.border}; bit < 5; bit++ {
336+ if mask&(1<<bit) == 0 {
337+ continue
338+ }
339+ v := uint16(binary.LittleEndian.Uint32(vals))
340+ vals = vals[4:]
341+ switch d := dst[bit].(type) {
342+ case *int16:
343+ *d = int16(v)
344+ case *uint16:
345+ *d = v
346+ }
347+ }
348+ p.geoms[binary.LittleEndian.Uint32(body)] = g
349+ case r.major == opGetGeometry:
350+ g := p.geoms[binary.LittleEndian.Uint32(body)]
351+ b := make([]byte, 24)
352+ binary.LittleEndian.PutUint32(b[0:], peerRoot)
353+ binary.LittleEndian.PutUint16(b[4:], uint16(g.x))
354+ binary.LittleEndian.PutUint16(b[6:], uint16(g.y))
355+ binary.LittleEndian.PutUint16(b[8:], g.w)
356+ binary.LittleEndian.PutUint16(b[10:], g.h)
357+ binary.LittleEndian.PutUint16(b[12:], g.border)
358+ p.reply(24, b)
359+ case r.major == opChangeProperty:
360+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
361+ format, n := body[12], int(binary.LittleEndian.Uint32(body[16:]))
362+ if p.props[win] == nil {
363+ p.props[win] = map[uint32]xpeerProp{}
364+ }
365+ p.props[win][prop] = xpeerProp{
366+ typ: binary.LittleEndian.Uint32(body[8:]), format: format,
367+ data: append([]byte(nil), body[20:20+n*int(format)/8]...),
368+ }
369+ case r.major == opDeleteProperty:
370+ delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:]))
371+ case r.major == opGetProperty:
372+ win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:])
373+ off, length := int(binary.LittleEndian.Uint32(body[12:]))*4, int(binary.LittleEndian.Uint32(body[16:]))*4
374+ v, ok := p.props[win][prop]
375+ data, after := v.data, 0
376+ if off > len(data) {
377+ off = len(data)
378+ }
379+ data = data[off:]
380+ if len(data) > length {
381+ data, after = data[:length], len(data)-length
382+ }
383+ b := make([]byte, 24+(len(data)+3)&^3)
384+ if ok && v.format != 0 {
385+ binary.LittleEndian.PutUint32(b[0:], v.typ)
386+ binary.LittleEndian.PutUint32(b[4:], uint32(after))
387+ binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(v.format)/8)))
388+ copy(b[24:], data)
389+ if r.raw[1] != 0 && after == 0 { // delete
390+ delete(p.props[win], prop)
391+ }
392+ }
393+ p.reply(v.format, b)
394+ case r.major == opChangeWindowAttributes:
395+ const cwEventMask = 1 << 11
396+ if p.denyRoot && binary.LittleEndian.Uint32(body) == peerRoot && binary.LittleEndian.Uint32(body[4:])&cwEventMask != 0 {
397+ p.errorPacket(10, opChangeWindowAttributes, peerRoot) // BadAccess
398+ }
399+ case r.major >= 128:
400+ switch p.extName(r.major) {
401+ case "BIG-REQUESTS":
402+ p.reply(0, le32(peerMaxWords))
403+ case "XC-MISC":
404+ if r.minor == 1 {
405+ p.reply(0, le32(0, 1))
406+ }
407+ case "RENDER":
408+ if r.minor == 1 { // QueryPictFormats: one format (ARGB32), unless noARGB
409+ n := uint32(1)
410+ if p.noARGB {
411+ n = 0
412+ }
413+ b := make([]byte, 24+28*int(n))
414+ binary.LittleEndian.PutUint32(b[0:], n)
415+ if n == 1 {
416+ f := b[24:]
417+ binary.LittleEndian.PutUint32(f[0:], 0x77) // format id
418+ f[4], f[5] = 1, 32 // direct, depth 32
419+ binary.LittleEndian.PutUint16(f[8:], 16) // red shift
420+ binary.LittleEndian.PutUint16(f[10:], 0xff)
421+ binary.LittleEndian.PutUint16(f[20:], 24) // alpha shift
422+ binary.LittleEndian.PutUint16(f[22:], 0xff)
423+ }
424+ p.reply(0, b)
425+ }
426+ case "Composite", "XFIXES":
427+ if r.minor == 0 {
428+ p.reply(0, le32(5, 4))
429+ }
430+ case "X-Resource":
431+ switch r.minor {
432+ case 0:
433+ p.reply(0, []byte{1, 0, 2, 0})
434+ case 4: // QueryClientIds: one spec, answered when a pid is scripted
435+ pid, ok := p.pids[r.u32(8)]
436+ if !ok {
437+ p.reply(0, le32(0))
438+ break
439+ }
440+ b := make([]byte, 40)
441+ binary.LittleEndian.PutUint32(b[0:], 1)
442+ binary.LittleEndian.PutUint32(b[24:], r.u32(8))
443+ binary.LittleEndian.PutUint32(b[28:], 2) // LocalClientPID
444+ binary.LittleEndian.PutUint32(b[32:], 4)
445+ binary.LittleEndian.PutUint32(b[36:], pid)
446+ p.reply(0, b)
447+ }
448+ }
449+ }
450+}
451+
452+// requests returns a snapshot of every request received after setup.
453+func (p *xpeer) requests() []xpeerReq {
454+ p.mu.Lock()
455+ defer p.mu.Unlock()
456+ return append([]xpeerReq(nil), p.reqs...)
457+}
458+
459+// waitReq waits for a request satisfying match among those after the first
460+// skip requests, and returns it with the new count.
461+func (p *xpeer) waitReq(skip int, match func(xpeerReq) bool) (xpeerReq, int) {
462+ p.t.Helper()
463+ deadline := time.Now().Add(5 * time.Second)
464+ for time.Now().Before(deadline) {
465+ reqs := p.requests()
466+ for i := skip; i < len(reqs); i++ {
467+ if match(reqs[i]) {
468+ return reqs[i], i + 1
469+ }
470+ }
471+ time.Sleep(time.Millisecond)
472+ }
473+ p.t.Fatal("xpeer: expected request never arrived")
474+ return xpeerReq{}, 0
475+}
476+
477+func (p *xpeer) atom(name string) uint32 {
478+ p.mu.Lock()
479+ defer p.mu.Unlock()
480+ return p.atoms[name]
481+}
482+
483+func (p *xpeer) prop(win uint32, atom string) (xpeerProp, bool) {
484+ p.mu.Lock()
485+ defer p.mu.Unlock()
486+ v, ok := p.props[win][p.atoms[atom]]
487+ return v, ok
488+}
489+
490+// event sends a 32-byte core event stamped with the current sequence number.
491+func (p *xpeer) event(code uint8, fill func(b *[32]byte)) {
492+ p.mu.Lock()
493+ defer p.mu.Unlock()
494+ var b [32]byte
495+ b[0] = code
496+ binary.LittleEndian.PutUint16(b[2:], p.seq)
497+ if fill != nil {
498+ fill(&b)
499+ }
500+ p.write(b[:])
501+}
502+
503+func (p *xpeer) createNotify(win uint32, override bool) {
504+ p.event(16, func(b *[32]byte) {
505+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
506+ binary.LittleEndian.PutUint32(b[8:], win)
507+ binary.LittleEndian.PutUint16(b[16:], 100)
508+ binary.LittleEndian.PutUint16(b[18:], 50)
509+ if override {
510+ b[22] = 1
511+ }
512+ })
513+}
514+
515+func (p *xpeer) destroyNotify(win uint32) {
516+ p.event(17, func(b *[32]byte) {
517+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
518+ binary.LittleEndian.PutUint32(b[8:], win)
519+ })
520+}
521+
522+// pause makes the peer stop reading after the request it is serving.
523+func (p *xpeer) pause() {
524+ g := make(chan struct{})
525+ p.gate.Store(&g)
526+}
527+
528+// resume lets the peer read again.
529+func (p *xpeer) resume() {
530+ g := *p.gate.Load()
531+ open := make(chan struct{})
532+ close(open)
533+ p.gate.Store(&open)
534+ select {
535+ case <-g:
536+ default:
537+ close(g)
538+ }
539+}
540+
541+// set mutates the peer's script under its lock.
542+func (p *xpeer) set(fn func(p *xpeer)) {
543+ p.mu.Lock()
544+ defer p.mu.Unlock()
545+ fn(p)
546+}
547+
548+// hangup closes the server end; the client reads EOF.
549+func (p *xpeer) hangup() {
550+ if rc, err := p.f.SyscallConn(); err == nil {
551+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
552+ }
553+}
554+
555+// setProp scripts a window property for GetProperty.
556+func (p *xpeer) setProp(win, atom, typ uint32, format uint8, data []byte) {
557+ p.mu.Lock()
558+ defer p.mu.Unlock()
559+ if p.props[win] == nil {
560+ p.props[win] = map[uint32]xpeerProp{}
561+ }
562+ p.props[win][atom] = xpeerProp{typ: typ, format: format, data: data}
563+}
564+
565+func (p *xpeer) mapRequest(win uint32) {
566+ p.event(20, func(b *[32]byte) {
567+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
568+ binary.LittleEndian.PutUint32(b[8:], win)
569+ })
570+}
571+
572+func (p *xpeer) mapNotify(win uint32) {
573+ p.event(19, func(b *[32]byte) {
574+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
575+ binary.LittleEndian.PutUint32(b[8:], win)
576+ })
577+}
578+
579+func (p *xpeer) unmapNotify(win uint32, synthetic bool) {
580+ p.event(18, func(b *[32]byte) {
581+ if synthetic {
582+ b[0] |= 0x80
583+ }
584+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
585+ binary.LittleEndian.PutUint32(b[8:], win)
586+ })
587+}
588+
589+func (p *xpeer) propertyNotify(win, atom uint32, deleted bool) {
590+ p.event(28, func(b *[32]byte) {
591+ binary.LittleEndian.PutUint32(b[4:], win)
592+ binary.LittleEndian.PutUint32(b[8:], atom)
593+ if deleted {
594+ b[16] = 1
595+ }
596+ })
597+}
598+
599+func (p *xpeer) reparentNotify(win, parent uint32) {
600+ p.event(21, func(b *[32]byte) {
601+ binary.LittleEndian.PutUint32(b[4:], peerRoot)
602+ binary.LittleEndian.PutUint32(b[8:], win)
603+ binary.LittleEndian.PutUint32(b[12:], parent)
604+ })
605+}
606+
607+func (p *xpeer) clientMessage(win, typ uint32, data ...uint32) {
608+ p.event(33, func(b *[32]byte) {
609+ b[1] = 32
610+ binary.LittleEndian.PutUint32(b[4:], win)
611+ binary.LittleEndian.PutUint32(b[8:], typ)
612+ for i, d := range data {
613+ binary.LittleEndian.PutUint32(b[12+4*i:], d)
614+ }
615+ })
616+}
617+
618+func (p *xpeer) focusIn(win uint32) {
619+ p.event(9, func(b *[32]byte) { binary.LittleEndian.PutUint32(b[4:], win) })
620+}
621+
622+func (p *xpeer) configureNotify(win uint32) {
623+ p.event(22, func(b *[32]byte) {
624+ binary.LittleEndian.PutUint32(b[4:], win)
625+ binary.LittleEndian.PutUint32(b[8:], win)
626+ binary.LittleEndian.PutUint16(b[20:], 10)
627+ binary.LittleEndian.PutUint16(b[22:], 20)
628+ binary.LittleEndian.PutUint16(b[24:], 300)
629+ binary.LittleEndian.PutUint16(b[26:], 200)
630+ })
631+}
A internal/adapters/wayland/xwm/xsettings_test.go +205 -0
@@ -0,0 +1,205 @@
1+package xwm
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "testing"
7+
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func bootSettings(t *testing.T, cfg ports.XwaylandSettings, mutate func(p *xpeer)) (*XWM, *xpeer) {
13+ t.Helper()
14+ fd, p := startXPeer(t, fullPeerExts())
15+ if mutate != nil {
16+ p.set(mutate)
17+ }
18+ x, err := BootSettings(context.Background(), fd, testLog(), cfg)
19+ require.NoError(t, err)
20+ t.Cleanup(x.CloseConn)
21+ return x, p
22+}
23+
24+func (p *xpeer) count(major uint8, match func(xpeerReq) bool) (n int) {
25+ for _, r := range p.requests() {
26+ if r.major == major && (match == nil || match(r)) {
27+ n++
28+ }
29+ }
30+ return n
31+}
32+
33+func TestXSettingsEncode(t *testing.T) {
34+ s := &xsettings{serial: 7, theme: "ab", size: 24, dpi: 96 * 1024, themeSerial: 7, sizeSerial: 5, dpiSerial: 3}
35+ n := s.encode()
36+ u32 := binary.LittleEndian.AppendUint32
37+ var want []byte
38+ want = append(want, 0, 0, 0, 0)
39+ want = u32(want, 7)
40+ want = u32(want, 3)
41+ // Gtk/CursorThemeName STRING "ab"
42+ want = append(want, 1, 0, 19, 0)
43+ want = append(want, "Gtk/CursorThemeName"...)
44+ want = append(want, 0) // 19 -> 20
45+ want = u32(want, 7)
46+ want = u32(want, 2)
47+ want = append(want, 'a', 'b', 0, 0)
48+ // Gtk/CursorThemeSize INT 24
49+ want = append(want, 0, 0, 19, 0)
50+ want = append(want, "Gtk/CursorThemeSize"...)
51+ want = append(want, 0)
52+ want = u32(want, 5)
53+ want = u32(want, 24)
54+ // Xft/DPI INT (name length 7 -> pad 1)
55+ want = append(want, 0, 0, 7, 0)
56+ want = append(want, "Xft/DPI"...)
57+ want = append(want, 0)
58+ want = u32(want, 3)
59+ want = u32(want, 96*1024)
60+ require.Equal(t, want, s.buf[:n])
61+ // Name lengths 1..4 mod 4 pad to a multiple of 4.
62+ for l := 1; l <= 8; l++ {
63+ require.Zero(t, pad4(l)%4)
64+ require.GreaterOrEqual(t, pad4(l), l)
65+ require.Less(t, pad4(l)-l, 4)
66+ }
67+}
68+
69+func TestXSettingsOwnership(t *testing.T) {
70+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 32}, nil)
71+ win := x.xs.win
72+ require.NotZero(t, win)
73+ s0, mgr := p.atom("_XSETTINGS_S0"), p.atom("MANAGER")
74+ own := -1
75+ for i, r := range p.requests() {
76+ if r.major == opSetSelectionOwner && r.u32(8) == s0 {
77+ own = i
78+ require.Equal(t, win, r.u32(4))
79+ }
80+ }
81+ require.NotEqual(t, -1, own)
82+ var sent bool
83+ for _, r := range p.requests() {
84+ if r.major == opSendEvent && r.u32(4) == peerRoot && r.u32(20) == mgr {
85+ sent = true
86+ require.Equal(t, s0, r.u32(28))
87+ require.Equal(t, win, r.u32(32))
88+ }
89+ }
90+ require.True(t, sent, "MANAGER client message to the root")
91+ prop, ok := p.prop(win, "_XSETTINGS_SETTINGS")
92+ require.True(t, ok)
93+ require.Equal(t, p.atom("_XSETTINGS_SETTINGS"), prop.typ)
94+ require.EqualValues(t, 8, prop.format)
95+
96+ // SelectionClear stops updates.
97+ require.True(t, x.xs.active)
98+ p.event(29, func(b *[32]byte) {
99+ binary.LittleEndian.PutUint32(b[8:], win)
100+ binary.LittleEndian.PutUint32(b[12:], s0)
101+ })
102+ require.NoError(t, x.conn.Sync(&x.h))
103+ require.False(t, x.xs.active)
104+ before := p.count(opChangeProperty, nil)
105+ var sp Space
106+ sp.Set(ports.Layout{{Width: 10, Height: 10, Scale: 2}})
107+ x.space = &sp
108+ x.refreshXSettings()
109+ require.NoError(t, x.conn.Flush())
110+ require.NoError(t, x.conn.Sync(&x.h))
111+ require.Equal(t, before, p.count(opChangeProperty, nil))
112+}
113+
114+func TestXSettingsExistingOwner(t *testing.T) {
115+ var s0 uint32
116+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true}, func(p *xpeer) {
117+ p.selOwner = map[uint32]uint32{}
118+ // atoms are assigned on intern: pre-assign through a known name.
119+ p.nextAtom++
120+ s0 = p.nextAtom
121+ p.atoms["_XSETTINGS_S0"] = s0
122+ p.selOwner[s0] = 0xabc
123+ })
124+ require.False(t, x.xs.active)
125+ require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 }))
126+ _, ok := p.prop(x.xs.win, "_XSETTINGS_SETTINGS")
127+ require.False(t, ok)
128+}
129+
130+func TestXSettingsOff(t *testing.T) {
131+ _, p := bootSettings(t, ports.XwaylandSettings{XSettings: false, CursorSize: 24}, nil)
132+ s0 := p.atom("_XSETTINGS_S0")
133+ require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 && s0 != 0 }))
134+ require.Zero(t, p.count(opGetSelectionOwner, nil))
135+}
136+
137+// decodeSettings returns serial and the per-setting last-change serial by name.
138+func decodeSettings(t *testing.T, b []byte) (uint32, map[string]uint32) {
139+ t.Helper()
140+ le := binary.LittleEndian
141+ out := map[string]uint32{}
142+ n := int(le.Uint32(b[8:]))
143+ off := 12
144+ for i := 0; i < n; i++ {
145+ typ, nl := b[off], int(le.Uint16(b[off+2:]))
146+ off += 4
147+ name := string(b[off : off+nl])
148+ off += pad4(nl)
149+ out[name] = le.Uint32(b[off:])
150+ off += 4
151+ if typ == xsString {
152+ off += 4 + pad4(int(le.Uint32(b[off:])))
153+ } else {
154+ off += 4
155+ }
156+ }
157+ return le.Uint32(b[4:]), out
158+}
159+
160+func TestXSettingsSerial(t *testing.T) {
161+ x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorSize: 24}, nil)
162+ win := x.xs.win
163+ props := func() int {
164+ return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win })
165+ }
166+ require.Equal(t, 1, props())
167+ layout := func(scale float64) {
168+ var sp Space
169+ sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}})
170+ x.space = &sp
171+ x.LayoutChanged(true)
172+ require.NoError(t, x.conn.Flush())
173+ require.NoError(t, x.conn.Sync(&x.h))
174+ }
175+ layout(1) // same settings as boot
176+ require.Equal(t, 1, props())
177+ layout(2)
178+ require.Equal(t, 2, props())
179+ prop, _ := p.prop(win, "_XSETTINGS_SETTINGS")
180+ serial, last := decodeSettings(t, prop.data)
181+ require.EqualValues(t, 2, serial)
182+ require.EqualValues(t, 2, last["Xft/DPI"])
183+ require.EqualValues(t, 1, last["Gtk/CursorThemeSize"])
184+ require.EqualValues(t, 1, last["Gtk/CursorThemeName"])
185+ require.EqualValues(t, 192*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:])))
186+
187+ x.xs.serial = 0xffffffff
188+ x.xs.dpi = 0
189+ x.publishXSettings(false)
190+ require.EqualValues(t, 0, x.xs.serial, "wraps")
191+}
192+
193+func TestXSettingsNoChangeAllocations(t *testing.T) {
194+ x, _ := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 24}, nil)
195+ var sp Space
196+ sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}})
197+ x.space = &sp
198+ x.LayoutChanged(true)
199+ l := ports.Layout{{Width: 100, Height: 100, Scale: 1}}
200+ n := testing.AllocsPerRun(100, func() {
201+ sp.Set(l)
202+ x.LayoutChanged(false)
203+ })
204+ require.Zero(t, n)
205+}
A internal/adapters/wayland/xwm/xwm.go +488 -0
@@ -0,0 +1,488 @@
1+// Package xwm is the built-in X11 window manager for Xwayland: it speaks the
2+// X protocol over the -wm socket with xturbo and reports managed windows to
3+// the Wayland server through the unexported host interface.
4+//
5+// Threading: Boot runs on the starter goroutine and blocks. The XWM it returns
6+// is then handed to the display goroutine, which owns it exclusively; nothing
7+// in this package is locked.
8+package xwm
9+
10+import (
11+ "context"
12+ "encoding/binary"
13+ "errors"
14+ "fmt"
15+ "io"
16+ "os"
17+ "time"
18+
19+ "bnema.dev/xturbo"
20+ "bnema.dev/xturbo/composite"
21+ "bnema.dev/xturbo/res"
22+ "bnema.dev/xturbo/xfixes"
23+ "bnema.dev/xturbo/xproto"
24+ "github.com/bnema/purego-libwayland/server"
25+ "github.com/bnema/zerowrap"
26+ "golang.org/x/sys/unix"
27+
28+ "github.com/bnema/neferwl/internal/ports"
29+)
30+
31+var (
32+ // ErrWMExists is returned by Boot when another client already redirects
33+ // the root window's children.
34+ ErrWMExists = errors.New("xwm: another window manager owns the root window")
35+ // ErrNoComposite is returned by Boot when the X server lacks Composite.
36+ ErrNoComposite = errors.New("xwm: X server has no Composite extension")
37+)
38+
39+// Stats are connection and event counters, for diagnostics.
40+type Stats struct {
41+ Events, Replies, Errors, Unattributed, Stale, RingFull, LegacyID, UnmanagedSeen uint64
42+}
43+
44+// Version request ids, carried in the first Expect argument of kindVersion.
45+const (
46+ versionComposite uint32 = iota + 1
47+ versionXFixes
48+ versionRes
49+)
50+
51+const unpairedCap = 64
52+
53+// XWM manages the windows of one Xwayland. Display goroutine only, after Boot.
54+type XWM struct {
55+ conn *xturbo.Conn
56+ log zerowrap.Logger
57+ root uint32
58+ wmWin uint32
59+ selWin uint32 // selection bridge window (selection.go)
60+ sel selBridge
61+ atoms [atomCount]uint32
62+ tab *table
63+ h xturbo.Handler
64+ stats Stats
65+
66+ // attachment
67+ host host
68+ src fdSource
69+ addFD func(fd int, onReadable, onWritable func()) (fdSource, error) // display fd source seam
70+ after func(d time.Duration, fn func()) (cancel func())
71+ post func(func())
72+ flushFn func()
73+
74+ posted, writable, closed bool
75+ bootErr error
76+ warnedRing, warnedFull bool
77+ warnedUnpaired bool
78+
79+ scratch []byte // encode buffer for 32-bit property values, 4 bytes per slot
80+
81+ // Shared by the map, configure and focus flows.
82+ dirty []Slot // slots with a pending configure, capacity == table size
83+ clients []uint32 // _NET_CLIENT_LIST, map order
84+ stacking []uint32 // _NET_CLIENT_LIST_STACKING, bottom to top
85+ active Slot // slot named by _NET_ACTIVE_WINDOW, 0 = none
86+ refocus bool // a FocusIn was denied; focus is re-issued in flush
87+ listsDirty uint8 // client lists to rewrite in flush
88+ unpaired [unpairedCap]struct{ serial, surface uint64 }
89+ unpairedN int
90+
91+ cfg ports.XwaylandSettings
92+ xs xsettings // XSETTINGS manager and root cursor (xsettings.go, cursor.go)
93+
94+ space *Space // layout <-> X coordinates, shared with the host
95+ warnedSpace bool // more outputs than the Space holds, logged once
96+ ovDirty []Slot // mapped unmanaged slots whose geometry changed, capacity == table size
97+}
98+
99+func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM {
100+ x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)}
101+ x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError}
102+ x.fit()
103+ x.initSelection()
104+ return x
105+}
106+
107+// fit sizes the per-slot buffers to the table; called after every insert.
108+func (x *XWM) fit() {
109+ n := len(x.tab.recs)
110+ if cap(x.dirty) < n {
111+ x.dirty = append(make([]Slot, 0, n), x.dirty...)
112+ x.clients = append(make([]uint32, 0, n), x.clients...)
113+ x.ovDirty = append(make([]Slot, 0, n), x.ovDirty...)
114+ x.stacking = append(make([]uint32, 0, n), x.stacking...)
115+ x.scratch = make([]byte, 0, 4*n)
116+ }
117+}
118+
119+// insert adds a record for xid; 0 means the table is full.
120+func (x *XWM) insert(xid uint32) Slot {
121+ s := x.tab.insert(xid)
122+ if s == 0 {
123+ if !x.warnedFull {
124+ x.warnedFull = true
125+ x.log.Warn().Int("windows", x.tab.n).Msg("X11 window table full, window ignored")
126+ }
127+ return 0
128+ }
129+ x.fit()
130+ return s
131+}
132+
133+// expect registers seq, tolerating a full ring: the reply then arrives
134+// unattributed and is dropped.
135+func (x *XWM) expect(seq xturbo.Seq, kind uint16, a0, a1 uint32) {
136+ if seq == 0 {
137+ return
138+ }
139+ if err := x.conn.Expect(seq, kind, a0, a1, 0, 0); err != nil {
140+ x.stats.RingFull++
141+ if !x.warnedRing {
142+ x.warnedRing = true
143+ x.log.Warn().Err(err).Msg("X11 request not tracked")
144+ }
145+ }
146+}
147+
148+// setProp32 queues ChangeProperty (replace, format 32) for vals.
149+func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) {
150+ n := 4 * len(vals)
151+ if cap(x.scratch) < n {
152+ x.scratch = make([]byte, 0, n)
153+ }
154+ p := x.scratch[:n]
155+ for i, v := range vals {
156+ binary.LittleEndian.PutUint32(p[4*i:], v)
157+ }
158+ xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(win),
159+ xproto.Atom(x.atoms[prop]), typ, 32, uint32(len(vals)), p)
160+}
161+
162+// Boot connects to the X server on wmFD and becomes its window manager. It
163+// blocks (about 25 s at worst) and must not run on the display goroutine: the
164+// handshake needs Xwayland, which needs the display goroutine to serve its
165+// Wayland requests. It takes ownership of wmFD and closes it on failure.
166+// Boot gives up promptly when ctx ends (the Xwayland process died or the run
167+// was cancelled). log is scoped by the caller with logging.For(ctx, "xwm").
168+func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) {
169+ return BootSettings(ctx, wmFD, log, ports.XwaylandSettings{})
170+}
171+
172+// BootSettings is Boot with the XSETTINGS manager and the root cursor of cfg.
173+func BootSettings(ctx context.Context, wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) {
174+ // The watcher shuts the socket down when ctx ends, which wakes any blocked
175+ // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown
176+ // can never reach a descriptor number reused after Boot closed wmFD.
177+ var watch *os.File
178+ if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil {
179+ watch = os.NewFile(uintptr(dup), "xwm abort")
180+ }
181+ stopWatch := func() {}
182+ if watch != nil {
183+ quit, joined := make(chan struct{}), make(chan struct{})
184+ go func() {
185+ defer close(joined)
186+ select {
187+ case <-ctx.Done():
188+ if rc, err := watch.SyscallConn(); err == nil {
189+ _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) })
190+ }
191+ case <-quit:
192+ }
193+ _ = watch.Close()
194+ }()
195+ stopWatch = func() { close(quit); <-joined }
196+ }
197+ x, err := boot(wmFD, log, cfg)
198+ stopWatch()
199+ if err == nil && ctx.Err() != nil {
200+ x.CloseConn()
201+ x, err = nil, ctx.Err()
202+ }
203+ if err != nil && ctx.Err() != nil {
204+ err = fmt.Errorf("%w (%w)", err, ctx.Err())
205+ }
206+ return x, err
207+}
208+
209+func boot(wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) {
210+ conn, err := xturbo.NewConn(wmFD)
211+ if err != nil {
212+ return nil, fmt.Errorf("xwm: connect: %w", err)
213+ }
214+ x := newXWM(conn, log)
215+ x.cfg = cfg
216+ if err := x.boot(); err != nil {
217+ _ = conn.Close()
218+ return nil, err
219+ }
220+ return x, nil
221+}
222+
223+func (x *XWM) boot() error {
224+ conn := x.conn
225+ ext := conn.Ext()
226+ if !ext.Info(xturbo.ExtComposite).Present {
227+ return ErrNoComposite
228+ }
229+ var visual, depth uint32
230+ if it := xproto.NewSetupIter(conn.SetupBytes(), 1); it.Next() {
231+ if roots := it.Value().Roots(); roots.Next() {
232+ visual, depth = uint32(roots.Value().RootVisual()), uint32(roots.Value().RootDepth())
233+ }
234+ }
235+ x.log.Info().Uint32("root", x.root).Uint32("visual", visual).Uint32("depth", depth).
236+ Bool("xfixes", ext.Info(xturbo.ExtXFixes).Present).
237+ Bool("res", ext.Info(xturbo.ExtRes).Present).Msg("X server")
238+
239+ if err := conn.InternAtoms(atomNames[:], false, x.atoms[:]); err != nil {
240+ return fmt.Errorf("xwm: intern atoms: %w", err)
241+ }
242+ xid, err := conn.NewXID()
243+ if err != nil {
244+ return fmt.Errorf("xwm: allocate window id: %w", err)
245+ }
246+ x.wmWin = xid
247+ if ext.Info(xturbo.ExtXFixes).Present {
248+ if x.selWin, err = conn.NewXID(); err != nil {
249+ return fmt.Errorf("xwm: allocate window id: %w", err)
250+ }
251+ }
252+
253+ b := conn.Buf()
254+ x.expect(composite.QueryVersion(b, 0, 4), kindVersion, versionComposite, 0)
255+ if ext.Info(xturbo.ExtXFixes).Present {
256+ x.expect(xfixes.QueryVersion(b, 6, 0), kindVersion, versionXFixes, 0)
257+ }
258+ if ext.Info(xturbo.ExtRes).Present {
259+ x.expect(res.QueryVersion(b, 1, 2), kindVersion, versionRes, 0)
260+ }
261+
262+ root := xproto.Window(x.root)
263+ var rootMask xproto.ChangeWindowAttributesValues
264+ rootMask.SetEventMask(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
265+ x.expect(xproto.ChangeWindowAttributes(b, root, &rootMask), kindRootMask, 0, 0)
266+ // Only one client may select SubstructureRedirect on the root: wait for
267+ // the answer before touching anything another window manager owns.
268+ if err := b.Err(); err != nil {
269+ return fmt.Errorf("xwm: encode root mask: %w", err)
270+ }
271+ if err := conn.Sync(&x.h); err != nil {
272+ return fmt.Errorf("xwm: root mask: %w", err)
273+ }
274+ if x.bootErr != nil {
275+ return x.bootErr
276+ }
277+ composite.RedirectSubwindows(b, root, composite.RedirectManual)
278+
279+ var wv xproto.CreateWindowValues
280+ wv.SetEventMask(0)
281+ xproto.CreateWindow(b, 0, xproto.Window(x.wmWin), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOutput, 0, &wv)
282+ name := []byte("neferwl")
283+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMName]),
284+ xproto.Atom(x.atoms[atomUTF8String]), 8, uint32(len(name)), name)
285+ x.setProp32(x.root, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
286+ x.setProp32(x.wmWin, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin})
287+ // CurrentTime and no MANAGER announcement: this Xwayland is private to
288+ // neferwl and nobody waits on these selections.
289+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomWMS0]), 0)
290+ xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMCMS0]), 0)
291+
292+ x.bootSelection(root)
293+
294+ supported := [...]int{
295+ atomNetWMState, atomNetActiveWindow, atomNetWMStateFocused,
296+ atomNetWMStateModal, atomNetWMStateFullscreen, atomNetClientList,
297+ atomNetClientListStacking, atomNetSupportingWMCheck, atomNetWMName, atomNetWMPID,
298+ atomNetWMWindowType,
299+ }
300+ var list [len(supported)]uint32
301+ for i, a := range supported {
302+ list[i] = x.atoms[a]
303+ }
304+ x.setProp32(x.root, atomNetSupported, xproto.AtomAtom, list[:])
305+ x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, []uint32{0})
306+ x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, nil)
307+ x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, nil)
308+ xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0)
309+ if err := b.Err(); err != nil {
310+ return fmt.Errorf("xwm: encode setup requests: %w", err)
311+ }
312+ if err := conn.Sync(&x.h); err != nil {
313+ return fmt.Errorf("xwm: root setup: %w", err)
314+ }
315+ if x.bootErr != nil {
316+ return x.bootErr
317+ }
318+
319+ if err := x.bootSettings(); err != nil {
320+ return err
321+ }
322+
323+ // Windows that exist already (Xwayland restarted under a live client set).
324+ x.expect(xproto.QueryTree(b, root), kindTree, 0, 0)
325+ if err := conn.Sync(&x.h); err != nil {
326+ return fmt.Errorf("xwm: query tree: %w", err)
327+ }
328+ return x.bootErr
329+}
330+
331+// Attach registers the X socket with the display and hands the windows found
332+// at boot to h. Display goroutine only.
333+func (x *XWM) Attach(d *server.Display, h host) error {
334+ x.addFD = func(fd int, r, w func()) (fdSource, error) {
335+ s, err := d.AddFDRW(fd, r, w)
336+ if err != nil {
337+ return nil, err
338+ }
339+ return s, nil
340+ }
341+ src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable)
342+ if err != nil {
343+ return fmt.Errorf("xwm: watch X socket: %w", err)
344+ }
345+ x.attach(src, h, d.Post)
346+ return nil
347+}
348+
349+// attach is Attach over its seams: the event source and the display's Post.
350+func (x *XWM) attach(src fdSource, h host, post func(func())) {
351+ x.src, x.host, x.post = src, h, post
352+ x.after = h.After
353+ x.flushFn = func() {
354+ x.posted = false
355+ x.flush()
356+ }
357+ for i := range x.tab.recs {
358+ r := &x.tab.recs[i]
359+ if r.kind == kindUnmanaged && r.mapped && !r.ready { // mapped before Attach
360+ x.requestOverrideType(Slot(i + 1))
361+ }
362+ if r.kind != kindManaged || r.id != 0 {
363+ continue
364+ }
365+ s := Slot(i + 1)
366+ x.adopt(s)
367+ if r.mapping { // MapRequest or viewable before Attach
368+ r.mapping = false
369+ x.beginMap(s)
370+ }
371+ }
372+ x.flush()
373+}
374+
375+// Close detaches from the display, drops every window from the host and closes
376+// the X connection. Idempotent.
377+func (x *XWM) Close() {
378+ if x.closed {
379+ return
380+ }
381+ x.closed = true
382+ x.selClose()
383+ s := x.stats
384+ x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors).
385+ Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull).
386+ Uint64("legacy_id", s.LegacyID).Uint64("unmanaged_seen", s.UnmanagedSeen).Msg("X11 window management closed")
387+ if x.src != nil {
388+ x.src.Remove()
389+ x.src = nil
390+ }
391+ if x.host != nil {
392+ for i := range x.tab.recs {
393+ r := &x.tab.recs[i]
394+ switch {
395+ case r.kind == kindManaged && r.id != 0:
396+ r.id = 0
397+ x.host.Dissociate(Slot(i+1), true)
398+ case r.kind == kindUnmanaged && r.ovPaired:
399+ r.ovPaired = false
400+ x.host.OverrideGone(Slot(i + 1))
401+ }
402+ }
403+ }
404+ x.unpairedN = 0
405+ _ = x.conn.Close()
406+}
407+
408+// CloseConn closes the connection of an XWM that was never attached.
409+func (x *XWM) CloseConn() {
410+ if !x.closed {
411+ x.closed = true
412+ _ = x.conn.Close()
413+ }
414+}
415+
416+// fail ends window management after an unrecoverable connection error. Xwayland
417+// keeps running; its windows no longer map.
418+func (x *XWM) fail(err error) {
419+ if x.closed {
420+ return
421+ }
422+ if errors.Is(err, io.EOF) || errors.Is(err, xturbo.ErrClosed) {
423+ x.log.Info().Err(err).Msg("X11 window management stopped: Xwayland closed the connection")
424+ } else {
425+ x.log.Warn().Err(err).Msg("X11 window management stopped")
426+ }
427+ x.Close()
428+}
429+
430+// Stats returns the counters; Close logs them.
431+func (x *XWM) Stats() Stats { return x.stats }
432+
433+func (x *XWM) onReadable() {
434+ if x.closed {
435+ return
436+ }
437+ if err := x.conn.Dispatch(&x.h); err != nil {
438+ x.fail(err)
439+ return
440+ }
441+ x.flush()
442+}
443+
444+func (x *XWM) onWritable() { x.flush() }
445+
446+// wake schedules one flush at the end of the current display iteration.
447+func (x *XWM) wake() {
448+ if x.posted || x.closed || x.post == nil {
449+ return
450+ }
451+ x.posted = true
452+ x.post(x.flushFn)
453+}
454+
455+// flush sends what the handlers queued: pending configures, an XID prefetch,
456+// then the socket, arming POLLOUT only while it is full.
457+func (x *XWM) flush() {
458+ if x.closed {
459+ return
460+ }
461+ x.flushConfigures()
462+ if x.conn.XIDsLow() {
463+ _ = x.conn.PrefetchXIDs()
464+ }
465+ err := x.conn.Flush()
466+ switch {
467+ case err == nil:
468+ if x.writable {
469+ x.setWritable(false)
470+ }
471+ case errors.Is(err, xturbo.ErrWouldBlock):
472+ if !x.writable {
473+ x.setWritable(true)
474+ }
475+ default:
476+ x.fail(err)
477+ }
478+}
479+
480+func (x *XWM) setWritable(on bool) {
481+ x.writable = on
482+ if x.src == nil {
483+ return
484+ }
485+ if err := x.src.SetWritable(on); err != nil {
486+ x.fail(err)
487+ }
488+}
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
@@ -0,0 +1,387 @@
1+package xwm
2+
3+import (
4+ "context"
5+ "encoding/binary"
6+ "os"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "bnema.dev/xturbo/xproto"
12+ "github.com/bnema/neferwl/internal/ports"
13+ "github.com/bnema/zerowrap"
14+ "github.com/rs/zerolog"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func testLog() zerowrap.Logger { return zerowrap.Logger{Logger: zerolog.Nop()} }
20+
21+func bootPeer(t *testing.T, mutate func(p *xpeer)) (*XWM, *xpeer) {
22+ t.Helper()
23+ fd, p := startXPeer(t, fullPeerExts())
24+ if mutate != nil {
25+ p.set(mutate)
26+ }
27+ x, err := Boot(context.Background(), fd, testLog())
28+ require.NoError(t, err)
29+ t.Cleanup(x.CloseConn)
30+ return x, p
31+}
32+
33+func atomsOf(p xpeerProp) []uint32 {
34+ out := make([]uint32, len(p.data)/4)
35+ for i := range out {
36+ out[i] = binary.LittleEndian.Uint32(p.data[4*i:])
37+ }
38+ return out
39+}
40+
41+func TestBootRequests(t *testing.T) {
42+ x, p := bootPeer(t, nil)
43+ reqs := p.requests()
44+
45+ first := -1
46+ for i, r := range reqs {
47+ if r.major == opInternAtom {
48+ first = i
49+ break
50+ }
51+ }
52+ require.NotEqual(t, -1, first)
53+ for i := 0; i < atomCount; i++ {
54+ r := reqs[first+i]
55+ require.EqualValues(t, opInternAtom, r.major, i)
56+ n := int(binary.LittleEndian.Uint16(r.raw[4:]))
57+ require.Equal(t, atomNames[i], string(r.raw[8:8+n]), i)
58+ require.Equal(t, p.atom(atomNames[i]), x.atoms[i])
59+ }
60+
61+ // Root mask, then composite redirect, then the WM window and ownership.
62+ var rootMask, redirect, create, s0, cm, focus int
63+ rootMask, redirect, create, s0, cm, focus = -1, -1, -1, -1, -1, -1
64+ wm := x.wmWin
65+ for i := first + atomCount; i < len(reqs); i++ {
66+ r := reqs[i]
67+ switch {
68+ case r.major == opChangeWindowAttributes && r.u32(4) == peerRoot:
69+ rootMask = i
70+ require.EqualValues(t, 1<<11, r.u32(8))
71+ want := uint32(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange)
72+ require.Equal(t, want, r.u32(12))
73+ case r.major == 142 && r.minor == 2:
74+ redirect = i
75+ require.EqualValues(t, peerRoot, r.u32(4))
76+ require.EqualValues(t, 1, r.raw[8], "manual")
77+ case r.major == opCreateWindow && r.u32(4) == wm:
78+ create = i
79+ require.Equal(t, wm, r.u32(4))
80+ case r.major == opSetSelectionOwner && r.u32(8) == p.atom("WM_S0"):
81+ s0 = i
82+ require.Equal(t, wm, r.u32(4))
83+ case r.major == opSetSelectionOwner && r.u32(8) == p.atom("_NET_WM_CM_S0"):
84+ cm = i
85+ case r.major == opSetInputFocus:
86+ focus = i
87+ require.EqualValues(t, 0, r.u32(4), "focus None")
88+ }
89+ }
90+ for name, i := range map[string]int{"rootMask": rootMask, "redirect": redirect, "create": create, "WM_S0": s0, "CM_S0": cm, "focus": focus} {
91+ require.NotEqual(t, -1, i, name)
92+ }
93+ require.Less(t, rootMask, redirect)
94+ require.Less(t, redirect, create)
95+ require.Less(t, create, s0)
96+
97+ sup, ok := p.prop(peerRoot, "_NET_SUPPORTED")
98+ require.True(t, ok)
99+ var names []string
100+ byAtom := map[uint32]string{}
101+ for n, a := range p.atoms {
102+ byAtom[a] = n
103+ }
104+ for _, a := range atomsOf(sup) {
105+ names = append(names, byAtom[a])
106+ }
107+ require.Equal(t, []string{
108+ "_NET_WM_STATE", "_NET_ACTIVE_WINDOW", "_NET_WM_STATE_FOCUSED",
109+ "_NET_WM_STATE_MODAL", "_NET_WM_STATE_FULLSCREEN", "_NET_CLIENT_LIST",
110+ "_NET_CLIENT_LIST_STACKING", "_NET_SUPPORTING_WM_CHECK", "_NET_WM_NAME", "_NET_WM_PID",
111+ "_NET_WM_WINDOW_TYPE",
112+ }, names)
113+ require.NotContains(t, names, "_NET_WM_MOVERESIZE")
114+ require.NotContains(t, names, "_NET_WM_STATE_MAXIMIZED_VERT")
115+
116+ check, ok := p.prop(peerRoot, "_NET_SUPPORTING_WM_CHECK")
117+ require.True(t, ok)
118+ require.Equal(t, []uint32{wm}, atomsOf(check))
119+ check, ok = p.prop(wm, "_NET_SUPPORTING_WM_CHECK")
120+ require.True(t, ok)
121+ require.Equal(t, []uint32{wm}, atomsOf(check))
122+ name, ok := p.prop(wm, "_NET_WM_NAME")
123+ require.True(t, ok)
124+ require.Equal(t, "neferwl", string(name.data))
125+ active, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW")
126+ require.True(t, ok)
127+ require.Equal(t, []uint32{0}, atomsOf(active))
128+ for _, l := range []string{"_NET_CLIENT_LIST", "_NET_CLIENT_LIST_STACKING"} {
129+ v, ok := p.prop(peerRoot, l)
130+ require.True(t, ok, l)
131+ require.Empty(t, v.data, l)
132+ }
133+}
134+
135+func TestBootWMExists(t *testing.T) {
136+ fd, p := startXPeer(t, fullPeerExts())
137+ p.set(func(p *xpeer) { p.denyRoot = true })
138+ x, err := Boot(context.Background(), fd, testLog())
139+ require.ErrorIs(t, err, ErrWMExists)
140+ require.Nil(t, x)
141+ // Nothing the running window manager owns was touched.
142+ for _, r := range p.requests() {
143+ require.NotEqual(t, uint8(opSetSelectionOwner), r.major)
144+ if r.major == opChangeProperty {
145+ require.NotEqual(t, uint32(peerRoot), r.u32(4))
146+ }
147+ }
148+}
149+
150+func TestBootNoComposite(t *testing.T) {
151+ exts := fullPeerExts()
152+ delete(exts, "Composite")
153+ fd, _ := startXPeer(t, exts)
154+ _, err := Boot(context.Background(), fd, testLog())
155+ require.ErrorIs(t, err, ErrNoComposite)
156+}
157+
158+func TestBootOptionalExtensions(t *testing.T) {
159+ exts := fullPeerExts()
160+ delete(exts, "XFIXES")
161+ delete(exts, "X-Resource")
162+ fd, _ := startXPeer(t, exts)
163+ x, err := Boot(context.Background(), fd, testLog())
164+ require.NoError(t, err)
165+ x.CloseConn()
166+}
167+
168+func TestBootConnectFailure(t *testing.T) {
169+ fd, p := startXPeer(t, fullPeerExts())
170+ p.hangup()
171+ _, err := Boot(context.Background(), fd, testLog())
172+ require.Error(t, err)
173+}
174+
175+func TestBootExistingWindows(t *testing.T) {
176+ x, p := bootPeer(t, func(p *xpeer) {
177+ p.children = []uint32{0x400001, 0x400002}
178+ p.attrs[0x400002] = [2]byte{1, 2} // override-redirect, viewable
179+ })
180+ _ = p
181+ require.NotZero(t, x.tab.lookup(0x400001))
182+ require.NotZero(t, x.tab.lookup(0x400002))
183+ require.Equal(t, kindManaged, x.tab.rec(x.tab.lookup(0x400001)).kind)
184+ require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x400002)).kind)
185+}
186+
187+// readUntil dispatches the socket until cond holds.
188+func readUntil(t *testing.T, x *XWM, cond func() bool) {
189+ t.Helper()
190+ deadline := time.Now().Add(5 * time.Second)
191+ for !cond() {
192+ if time.Now().After(deadline) {
193+ t.Fatal("condition never held")
194+ }
195+ x.onReadable()
196+ time.Sleep(time.Millisecond)
197+ }
198+}
199+
200+func newPost() (post func(func()), runs *[]func()) {
201+ var q []func()
202+ return func(f func()) { q = append(q, f) }, &q
203+}
204+
205+func TestAttachAnnouncesWindowsFoundAtBoot(t *testing.T) {
206+ x, _ := bootPeer(t, func(p *xpeer) {
207+ p.children = []uint32{0x400001, 0x400002}
208+ p.attrs[0x400002] = [2]byte{1, 2}
209+ })
210+ h := newMockhost(t)
211+ src := newMockfdSource(t)
212+ h.EXPECT().Created(x.tab.lookup(0x400001), uint32(0x400001)).Return(ports.WindowID(7)).Once()
213+ post, _ := newPost()
214+ x.attach(src, h, post)
215+ require.Equal(t, ports.WindowID(7), x.tab.rec(x.tab.lookup(0x400001)).id)
216+}
217+
218+func attached(t *testing.T) (*XWM, *xpeer, *mockhost, *mockfdSource, *[]func()) {
219+ t.Helper()
220+ x, p := bootPeer(t, nil)
221+ h := newMockhost(t)
222+ src := newMockfdSource(t)
223+ post, runs := newPost()
224+ x.attach(src, h, post)
225+ return x, p, h, src, runs
226+}
227+
228+func TestCreateAndDestroyManaged(t *testing.T) {
229+ x, p, h, src, _ := attached(t)
230+ const win = 0x500001
231+ h.EXPECT().Created(mock.Anything, uint32(win)).Return(ports.WindowID(3)).Once()
232+ p.createNotify(win, false)
233+ readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 })
234+ s := x.tab.lookup(win)
235+ require.Equal(t, kindManaged, x.tab.rec(s).kind)
236+ require.Equal(t, ports.WindowID(3), x.tab.rec(s).id)
237+
238+ // The window is watched for property and focus changes.
239+ req, _ := p.waitReq(0, func(r xpeerReq) bool { return r.major == opChangeWindowAttributes && r.u32(4) == win })
240+ require.EqualValues(t, xproto.EventMaskPropertyChange|xproto.EventMaskFocusChange, req.u32(12))
241+
242+ h.EXPECT().Dissociate(s, true).Once()
243+ p.destroyNotify(win)
244+ readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 })
245+ require.Zero(t, x.tab.n)
246+ _ = src
247+}
248+
249+// The X server reports the XWM's own check window to it like any other
250+// child of the root; it is never adopted.
251+func TestOwnWindowNeverAdopted(t *testing.T) {
252+ x, p, _, _, _ := attached(t) // the mock fails the test on any host call
253+ p.createNotify(x.wmWin, false)
254+ const marker = 0x500003
255+ p.createNotify(marker, true)
256+ readUntil(t, x, func() bool { return x.tab.lookup(marker) != 0 })
257+ require.Zero(t, x.tab.lookup(x.wmWin))
258+}
259+
260+func TestOverrideRedirectNeverReachesHost(t *testing.T) {
261+ x, p, _, _, _ := attached(t) // the mock fails the test on any host call
262+ p.createNotify(0x500002, true)
263+ readUntil(t, x, func() bool { return x.tab.lookup(0x500002) != 0 })
264+ require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x500002)).kind)
265+ require.EqualValues(t, 1, x.Stats().UnmanagedSeen)
266+ p.destroyNotify(0x500002)
267+ readUntil(t, x, func() bool { return x.tab.lookup(0x500002) == 0 })
268+}
269+
270+func TestCloseDissociatesEverything(t *testing.T) {
271+ x, p, h, src, _ := attached(t)
272+ for _, win := range []uint32{0x500001, 0x500002} {
273+ h.EXPECT().Created(mock.Anything, win).Return(ports.WindowID(win)).Once()
274+ p.createNotify(win, false)
275+ }
276+ p.createNotify(0x500003, true)
277+ readUntil(t, x, func() bool { return x.tab.lookup(0x500003) != 0 })
278+ h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once()
279+ h.EXPECT().Dissociate(x.tab.lookup(0x500002), true).Once()
280+ src.EXPECT().Remove().Once()
281+ x.Close()
282+ x.Close() // idempotent
283+ require.True(t, x.closed)
284+ require.Equal(t, -1, x.conn.Fd())
285+}
286+
287+func TestEOFClosesXWM(t *testing.T) {
288+ x, p, h, src, _ := attached(t)
289+ h.EXPECT().Created(mock.Anything, uint32(0x500001)).Return(ports.WindowID(1)).Once()
290+ p.createNotify(0x500001, false)
291+ readUntil(t, x, func() bool { return x.tab.lookup(0x500001) != 0 })
292+ h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once()
293+ src.EXPECT().Remove().Once()
294+ p.hangup()
295+ readUntil(t, x, func() bool { return x.closed })
296+}
297+
298+func TestWakeCoalescesFlush(t *testing.T) {
299+ x, _, _, _, runs := attached(t)
300+ x.wake()
301+ x.wake()
302+ x.wake()
303+ require.Len(t, *runs, 1)
304+ (*runs)[0]()
305+ x.wake()
306+ require.Len(t, *runs, 2)
307+}
308+
309+func TestWriteBackpressure(t *testing.T) {
310+ x, p, _, src, _ := attached(t)
311+ p.pause()
312+ b := x.conn.Buf()
313+ big := make([]byte, 64<<10)
314+ for i := 0; i < 64; i++ { // 4 MiB: more than any socket buffer
315+ xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.root), xproto.AtomWmName, xproto.AtomString, 8, uint32(len(big)), big)
316+ }
317+ src.EXPECT().SetWritable(true).Return(nil).Once()
318+ x.flush()
319+ require.True(t, x.writable)
320+ x.flush() // still blocked: SetWritable is not called again
321+ x.onWritable()
322+ require.True(t, x.writable)
323+
324+ drained := false
325+ src.EXPECT().SetWritable(false).RunAndReturn(func(bool) error { drained = true; return nil }).Once()
326+ p.resume()
327+ deadline := time.Now().Add(10 * time.Second)
328+ for !drained {
329+ require.True(t, time.Now().Before(deadline), "never drained")
330+ x.onWritable()
331+ time.Sleep(time.Millisecond)
332+ }
333+ require.False(t, x.writable)
334+}
335+
336+func TestFlushWithNothingQueuedSendsNothing(t *testing.T) {
337+ x, p, _, _, _ := attached(t)
338+ n := len(p.requests())
339+ x.flush()
340+ time.Sleep(20 * time.Millisecond)
341+ require.Len(t, p.requests(), n, "nothing queued, nothing sent")
342+}
343+
344+// bootOnlyFiles run only from boot() (XSETTINGS manager, root cursor).
345+var bootOnlyFiles = map[string]bool{"selection_xsettings.go": true, "cursor.go": true}
346+
347+// The XWM never blocks after Boot: NewXID, Sync and InternAtoms are startup
348+// only, so they may appear in production code only inside boot().
349+func TestNoBlockingCallsOutsideBoot(t *testing.T) {
350+ files, err := os.ReadDir(".")
351+ require.NoError(t, err)
352+ for _, f := range files {
353+ name := f.Name()
354+ if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
355+ continue
356+ }
357+ src, err := os.ReadFile(name)
358+ require.NoError(t, err)
359+ text := string(src)
360+ if bootOnlyFiles[name] {
361+ continue
362+ }
363+ lo, hi := 0, 0
364+ if name == "xwm.go" {
365+ lo = strings.Index(text, "func (x *XWM) boot() error")
366+ hi = strings.Index(text, "// Attach registers")
367+ require.Greater(t, hi, lo)
368+ }
369+ for _, call := range []string{".NewXID(", ".Sync(", ".InternAtoms("} {
370+ for _, at := range allIndex(text, call) {
371+ require.True(t, name == "xwm.go" && at > lo && at < hi, "%s: %s outside boot", name, call)
372+ }
373+ }
374+ }
375+}
376+
377+func allIndex(s, sub string) []int {
378+ var out []int
379+ for off := 0; ; {
380+ i := strings.Index(s[off:], sub)
381+ if i < 0 {
382+ return out
383+ }
384+ out = append(out, off+i)
385+ off += i + len(sub)
386+ }
387+}
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
@@ -0,0 +1,143 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package wayland
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// newMockxwmSelection creates a new instance of mockxwmSelection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func newMockxwmSelection(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *mockxwmSelection {
17+ if helper, ok := t.(interface{ Helper() }); ok {
18+ helper.Helper()
19+ }
20+
21+ mock := &mockxwmSelection{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() {
25+ if helper, ok := t.(interface{ Helper() }); ok {
26+ helper.Helper()
27+ }
28+ mock.AssertExpectations(t)
29+ })
30+
31+ return mock
32+}
33+
34+// mockxwmSelection is an autogenerated mock type for the xwmSelection type
35+type mockxwmSelection struct {
36+ mock.Mock
37+}
38+
39+type mockxwmSelection_Expecter struct {
40+ mock *mock.Mock
41+}
42+
43+func (_m *mockxwmSelection) EXPECT() *mockxwmSelection_Expecter {
44+ return &mockxwmSelection_Expecter{mock: &_m.Mock}
45+}
46+
47+// SelectionReceive provides a mock function for the type mockxwmSelection
48+func (_mock *mockxwmSelection) SelectionReceive(kind int, mime string, fd int) {
49+ _mock.Called(kind, mime, fd)
50+ return
51+}
52+
53+// mockxwmSelection_SelectionReceive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionReceive'
54+type mockxwmSelection_SelectionReceive_Call struct {
55+ *mock.Call
56+}
57+
58+// SelectionReceive is a helper method to define mock.On call
59+// - kind int
60+// - mime string
61+// - fd int
62+func (_e *mockxwmSelection_Expecter) SelectionReceive(kind any, mime any, fd any) *mockxwmSelection_SelectionReceive_Call {
63+ return &mockxwmSelection_SelectionReceive_Call{Call: _e.mock.On("SelectionReceive", kind, mime, fd)}
64+}
65+
66+func (_c *mockxwmSelection_SelectionReceive_Call) Run(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ var arg0 int
69+ if args[0] != nil {
70+ arg0 = args[0].(int)
71+ }
72+ var arg1 string
73+ if args[1] != nil {
74+ arg1 = args[1].(string)
75+ }
76+ var arg2 int
77+ if args[2] != nil {
78+ arg2 = args[2].(int)
79+ }
80+ run(
81+ arg0,
82+ arg1,
83+ arg2,
84+ )
85+ })
86+ return _c
87+}
88+
89+func (_c *mockxwmSelection_SelectionReceive_Call) Return() *mockxwmSelection_SelectionReceive_Call {
90+ _c.Call.Return()
91+ return _c
92+}
93+
94+func (_c *mockxwmSelection_SelectionReceive_Call) RunAndReturn(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call {
95+ _c.Run(run)
96+ return _c
97+}
98+
99+// WaylandSelection provides a mock function for the type mockxwmSelection
100+func (_mock *mockxwmSelection) WaylandSelection(kind int, mimes []string) {
101+ _mock.Called(kind, mimes)
102+ return
103+}
104+
105+// mockxwmSelection_WaylandSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaylandSelection'
106+type mockxwmSelection_WaylandSelection_Call struct {
107+ *mock.Call
108+}
109+
110+// WaylandSelection is a helper method to define mock.On call
111+// - kind int
112+// - mimes []string
113+func (_e *mockxwmSelection_Expecter) WaylandSelection(kind any, mimes any) *mockxwmSelection_WaylandSelection_Call {
114+ return &mockxwmSelection_WaylandSelection_Call{Call: _e.mock.On("WaylandSelection", kind, mimes)}
115+}
116+
117+func (_c *mockxwmSelection_WaylandSelection_Call) Run(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
118+ _c.Call.Run(func(args mock.Arguments) {
119+ var arg0 int
120+ if args[0] != nil {
121+ arg0 = args[0].(int)
122+ }
123+ var arg1 []string
124+ if args[1] != nil {
125+ arg1 = args[1].([]string)
126+ }
127+ run(
128+ arg0,
129+ arg1,
130+ )
131+ })
132+ return _c
133+}
134+
135+func (_c *mockxwmSelection_WaylandSelection_Call) Return() *mockxwmSelection_WaylandSelection_Call {
136+ _c.Call.Return()
137+ return _c
138+}
139+
140+func (_c *mockxwmSelection_WaylandSelection_Call) RunAndReturn(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call {
141+ _c.Run(run)
142+ return _c
143+}
A internal/adapters/xwayland/builtin.go +216 -0
@@ -0,0 +1,216 @@
1+package xwayland
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "os/exec"
9+ "strconv"
10+ "strings"
11+ "syscall"
12+ "time"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+type builtinStarter struct{}
18+
19+func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
20+ // Files own all setup descriptors until Start succeeds and Attach takes
21+ // the Wayland socket. This also unwinds partial setup failures.
22+ var files []*os.File
23+ defer func() {
24+ for _, f := range files {
25+ if f != nil {
26+ f.Close()
27+ }
28+ }
29+ }()
30+ pair := func(name string) (int, error) {
31+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
32+ if err != nil {
33+ return 0, err
34+ }
35+ i := len(files)
36+ files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child"))
37+ return i, nil
38+ }
39+ wl, err := pair("wayland")
40+ if err != nil {
41+ return err
42+ }
43+ wm, err := pair("wm")
44+ if err != nil {
45+ return err
46+ }
47+ var pipe [2]int
48+ if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil {
49+ return err
50+ }
51+ rd := os.NewFile(uintptr(pipe[0]), "displayfd read")
52+ wr := os.NewFile(uintptr(pipe[1]), "displayfd write")
53+ files = append(files, rd, wr)
54+ args := []string{d.Name(), "-rootless"}
55+ for i := range d.sockets {
56+ args = append(args, "-listenfd", strconv.Itoa(3+i))
57+ }
58+ wlChild := 3 + len(d.sockets)
59+ args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2))
60+ cmd := exec.Command(d.opts.Binary, args...)
61+ for _, e := range d.opts.Env {
62+ if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") {
63+ cmd.Env = append(cmd.Env, e)
64+ }
65+ }
66+ cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild))
67+ cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr)
68+ cmd.Stderr = stderr
69+ cmd.WaitDelay = 2 * time.Second
70+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
71+ if err := cmd.Start(); err != nil {
72+ return err
73+ }
74+ files[wl+1].Close()
75+ files[wm+1].Close()
76+ wr.Close()
77+ d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started")
78+ // Detach the Wayland descriptor from os.File ownership before handing it
79+ // to the display (including its finalizers).
80+ if d.opts.Attach != nil {
81+ // Dup transfers an independent descriptor; closing the File wrapper below
82+ // cannot race with the display's ownership of the transferred descriptor.
83+ var wlFD int
84+ wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0)
85+ if err == nil {
86+ d.opts.Attach(wlFD)
87+ }
88+ }
89+ files[wl].Close()
90+ done := make(chan error, 1)
91+ procGone := make(chan struct{})
92+ go func() { done <- cmd.Wait(); close(procGone) }()
93+ if err != nil {
94+ stopGroup(cmd, done)
95+ return err
96+ }
97+ exited, readyErr := waitReady(ctx, pipe[0], done)
98+ if readyErr != nil {
99+ err := readyErr
100+ if errors.Is(err, errReadinessTimeout) {
101+ d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out")
102+ }
103+ // Join Wait unless readiness already observed process exit.
104+ if !exited {
105+ stopGroup(cmd, done)
106+ }
107+ return err
108+ }
109+ rd.Close()
110+ d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready")
111+ if d.opts.AttachWM != nil {
112+ // The WM socket is bootstrapped only now: Xwayland serves it after its
113+ // Wayland round trips, which the display goroutine must be free to answer.
114+ wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0)
115+ if err != nil {
116+ stopGroup(cmd, done)
117+ return err
118+ }
119+ // The context ends with the process or the caller's context, so a
120+ // bootstrap against a dead or silent Xwayland aborts at once.
121+ wmCtx, cancel := context.WithCancel(ctx)
122+ watched := make(chan struct{})
123+ go func() {
124+ defer close(watched)
125+ select {
126+ case <-procGone:
127+ cancel()
128+ case <-wmCtx.Done():
129+ }
130+ }()
131+ stop := d.opts.AttachWM(wmCtx, wmFD)
132+ cancel()
133+ <-watched
134+ if stop != nil {
135+ // Runs before start returns so the restart loop never overlaps two XWMs.
136+ defer stop()
137+ }
138+ }
139+ select {
140+ case err := <-done:
141+ return err
142+ case <-ctx.Done():
143+ stopGroup(cmd, done)
144+ return ctx.Err()
145+ }
146+}
147+
148+var errReadinessTimeout = errors.New("xwayland readiness timed out")
149+
150+// waitReady uses bounded poll waits rather than a pipe reader goroutine.
151+// The result reports whether cmd.Wait was already joined.
152+func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) {
153+ deadline := time.Now().Add(10 * time.Second)
154+ var buf [32]byte
155+ n := 0
156+ for {
157+ select {
158+ case err := <-done:
159+ if err == nil {
160+ err = errors.New("xwayland exited before readiness")
161+ }
162+ return true, err
163+ default:
164+ }
165+ if ctx.Err() != nil {
166+ return false, ctx.Err()
167+ }
168+ if time.Now().After(deadline) {
169+ return false, errReadinessTimeout
170+ }
171+ // A closed pipe also covers process exit, including exec's stderr delay.
172+ p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
173+ _, err := unix.Poll(p, 50)
174+ if errors.Is(err, unix.EINTR) {
175+ continue
176+ }
177+ if err != nil {
178+ return false, err
179+ }
180+ if p[0].Revents == 0 {
181+ continue
182+ }
183+ if p[0].Revents&unix.POLLIN != 0 {
184+ count, err := unix.Read(fd, buf[n:])
185+ if errors.Is(err, unix.EINTR) {
186+ continue
187+ }
188+ if err != nil {
189+ return false, err
190+ }
191+ n += count
192+ if n > 0 && buf[n-1] == '\n' {
193+ if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil {
194+ return false, fmt.Errorf("xwayland displayfd: %w", err)
195+ }
196+ return false, nil
197+ }
198+ if n == len(buf) {
199+ return false, errors.New("invalid Xwayland displayfd response")
200+ }
201+ }
202+ if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 {
203+ return false, errors.New("xwayland exited before readiness")
204+ }
205+ }
206+}
207+
208+func stopGroup(cmd *exec.Cmd, done <-chan error) {
209+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM)
210+ select {
211+ case <-done:
212+ case <-time.After(2 * time.Second):
213+ _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL)
214+ <-done
215+ }
216+}
A internal/adapters/xwayland/starter_mock_test.go +110 -0
@@ -0,0 +1,110 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package xwayland
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func newMockstarter(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *mockstarter {
19+ if helper, ok := t.(interface{ Helper() }); ok {
20+ helper.Helper()
21+ }
22+
23+ mock := &mockstarter{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() {
27+ if helper, ok := t.(interface{ Helper() }); ok {
28+ helper.Helper()
29+ }
30+ mock.AssertExpectations(t)
31+ })
32+
33+ return mock
34+}
35+
36+// mockstarter is an autogenerated mock type for the starter type
37+type mockstarter struct {
38+ mock.Mock
39+}
40+
41+type mockstarter_Expecter struct {
42+ mock *mock.Mock
43+}
44+
45+func (_m *mockstarter) EXPECT() *mockstarter_Expecter {
46+ return &mockstarter_Expecter{mock: &_m.Mock}
47+}
48+
49+// start provides a mock function for the type mockstarter
50+func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
51+ ret := _mock.Called(ctx, d, stderr)
52+
53+ if len(ret) == 0 {
54+ panic("no return value specified for start")
55+ }
56+
57+ var r0 error
58+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok {
59+ r0 = returnFunc(ctx, d, stderr)
60+ } else {
61+ r0 = ret.Error(0)
62+ }
63+ return r0
64+}
65+
66+// mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start'
67+type mockstarter_start_Call struct {
68+ *mock.Call
69+}
70+
71+// start is a helper method to define mock.On call
72+// - ctx context.Context
73+// - d *Display
74+// - stderr *logWriter
75+func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call {
76+ return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)}
77+}
78+
79+func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 *Display
86+ if args[1] != nil {
87+ arg1 = args[1].(*Display)
88+ }
89+ var arg2 *logWriter
90+ if args[2] != nil {
91+ arg2 = args[2].(*logWriter)
92+ }
93+ run(
94+ arg0,
95+ arg1,
96+ arg2,
97+ )
98+ })
99+ return _c
100+}
101+
102+func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call {
103+ _c.Call.Return(err)
104+ return _c
105+}
106+
107+func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call {
108+ _c.Call.Return(run)
109+ return _c
110+}
M internal/adapters/xwayland/xwayland.go +46 -18
@@ -1,6 +1,5 @@
1-// Package xwayland runs X11 clients through xwayland-satellite, started on
2-// demand: neferwl owns the X11 display sockets and starts the satellite
3-// when the first X11 client connects, and again after it exits.
1+// Package xwayland starts an X11 backend on demand. NeferWL owns the display
2+// sockets and starts the selected backend when the first client connects.
43 package xwayland
54
65 import (
@@ -17,15 +16,26 @@ import (
1716 "syscall"
1817 "time"
1918
19+ "github.com/bnema/neferwl/internal/ports"
2020 "github.com/bnema/zerowrap"
2121 "golang.org/x/sys/unix"
2222 )
2323
2424 // Options configures the X11 display.
2525 type Options struct {
26- // Binary is the xwayland-satellite executable.
26+ Backend ports.XwaylandBackend
27+ // Attach takes ownership of the Wayland descriptor right after the backend
28+ // starts.
29+ Attach func(wlFD int)
30+ // AttachWM takes ownership of the window manager descriptor once the
31+ // backend is ready. It may block to bootstrap the X11 connection; ctx ends
32+ // when the backend exits or the run is cancelled, and the bootstrap must
33+ // then give up. The returned stop (nil for none) is called before the run
34+ // ends, on backend exit or context cancellation.
35+ AttachWM func(ctx context.Context, wmFD int) (stop func())
36+ // Binary is the selected backend executable.
2737 Binary string
28- // Env is the satellite's environment; it must hold WAYLAND_DISPLAY.
38+ // Env is the backend environment; satellite requires WAYLAND_DISPLAY.
2939 Env []string
3040 // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix
3141 // and /tmp outside tests.
@@ -35,8 +45,9 @@ type Options struct {
3545 Log zerowrap.Logger
3646 }
3747
38-// Display is an X11 display served by xwayland-satellite.
48+// Display is an on-demand X11 display.
3949 type Display struct {
50+ starter starter
4051 opts Options
4152 n int
4253 sockets []*os.File // unix socket, then the abstract one
@@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) {
8192 _ = os.Remove(lock)
8293 return nil, fmt.Errorf("write X11 lock: %w", err)
8394 }
84- d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
95+ d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))}
96+ if opts.Backend == ports.XwaylandBuiltin {
97+ d.starter = builtinStarter{}
98+ }
8599 if err := d.listen(); err != nil {
86100 last = err
87101 d.Close()
@@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error {
212226 return err
213227 }
214228 start := time.Now()
215- stderr := &logWriter{log: d.opts.Log}
216- err := d.serve(ctx, stderr)
229+ stderr := &logWriter{log: d.opts.Log, backend: d.backendName()}
230+ err := d.starter.start(ctx, d, stderr)
217231 if ctx.Err() != nil {
218232 return nil
219233 }
220234 if err != nil {
221- d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited")
235+ d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited")
222236 } else {
223- d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited")
237+ d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited")
224238 }
225239 // Clients the satellite never accepted would wake us at once: a
226240 // satellite that cannot start must not respawn in a loop.
@@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error {
261275 return ctx.Err()
262276 }
263277
264-// serve runs the satellite on the sockets until it exits or ctx ends.
265-func (d *Display) serve(ctx context.Context, stderr *logWriter) error {
278+// starter runs a backend on the reserved sockets until it exits or ctx ends.
279+type starter interface {
280+ start(ctx context.Context, d *Display, stderr *logWriter) error
281+}
282+
283+type satelliteStarter struct{}
284+
285+func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error {
266286 args := []string{d.Name()}
267287 for i := range d.sockets {
268288 args = append(args, "-listenfd", strconv.Itoa(3+i))
@@ -318,10 +338,11 @@ func (d *Display) dropPending() {
318338 // logWriter logs the satellite's stderr line by line and keeps the last
319339 // lines for the exit log; exec copies it from a pipe on its own goroutine.
320340 type logWriter struct {
321- mu sync.Mutex
322- log zerowrap.Logger
323- line []byte
324- last []string
341+ backend string
342+ mu sync.Mutex
343+ log zerowrap.Logger
344+ line []byte
345+ last []string
325346 }
326347
327348 // tail is the last lines written.
@@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) {
342363 }
343364 if i > 0 {
344365 line := string(w.line[:i])
345- w.log.Debug().Str("line", line).Msg("xwayland-satellite")
366+ w.log.Debug().Str("line", line).Msg(w.backend)
346367 if w.last = append(w.last, line); len(w.last) > 5 {
347368 w.last = w.last[1:]
348369 }
@@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) {
354375 }
355376 return len(p), nil
356377 }
378+
379+func (d *Display) backendName() string {
380+ if d.opts.Backend == ports.XwaylandBuiltin {
381+ return "Xwayland"
382+ }
383+ return "xwayland-satellite"
384+}
M internal/adapters/xwayland/xwayland_test.go +207 -0
@@ -11,6 +11,8 @@ import (
1111 "time"
1212
1313 "github.com/bnema/neferwl/internal/adapters/logging"
14+ "github.com/bnema/neferwl/internal/ports"
15+ "github.com/stretchr/testify/mock"
1416 "golang.org/x/sys/unix"
1517 )
1618
@@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) {
270272 }
271273 }
272274 }
275+
276+func TestStarterSelection(t *testing.T) {
277+ for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} {
278+ opts := testOptions(t, "true")
279+ opts.Backend = backend
280+ d, err := Open(opts)
281+ if err != nil {
282+ t.Fatal(err)
283+ }
284+ defer d.Close()
285+ switch d.starter.(type) {
286+ case satelliteStarter:
287+ if backend != ports.XwaylandSatellite {
288+ t.Fatal("satellite selected")
289+ }
290+ case builtinStarter:
291+ if backend != ports.XwaylandBuiltin {
292+ t.Fatal("builtin selected")
293+ }
294+ default:
295+ t.Fatalf("unexpected starter %T", d.starter)
296+ }
297+ }
298+}
299+
300+func TestRunCallsStarter(t *testing.T) {
301+ d, err := Open(testOptions(t, "unused"))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ defer d.Close()
306+ ctx, cancel := context.WithCancel(t.Context())
307+ defer cancel()
308+ m := newMockstarter(t)
309+ d.starter = m
310+ m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error {
311+ if got != d || stderr == nil {
312+ t.Error("starter arguments")
313+ }
314+ cancel()
315+ return ctx.Err()
316+ }).Once()
317+ done := make(chan error, 1)
318+ go func() { done <- d.Run(ctx) }()
319+ unix.Close(dial(t, d.socket))
320+ select {
321+ case err := <-done:
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ case <-time.After(3 * time.Second):
326+ t.Fatal("Run did not stop")
327+ }
328+}
329+
330+func TestWaitReady(t *testing.T) {
331+ for _, tt := range []struct {
332+ name, data string
333+ wantErr bool
334+ }{
335+ {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true},
336+ } {
337+ t.Run(tt.name, func(t *testing.T) {
338+ var p [2]int
339+ if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
340+ t.Fatal(err)
341+ }
342+ defer unix.Close(p[0])
343+ if tt.data != "" {
344+ if _, err := unix.Write(p[1], []byte(tt.data)); err != nil {
345+ t.Fatal(err)
346+ }
347+ }
348+ unix.Close(p[1])
349+ joined, err := waitReady(t.Context(), p[0], make(chan error))
350+ if joined || (err != nil) != tt.wantErr {
351+ t.Fatalf("joined=%v err=%v", joined, err)
352+ }
353+ })
354+ }
355+ t.Run("exit", func(t *testing.T) {
356+ done := make(chan error, 1)
357+ done <- errors.New("exit")
358+ joined, err := waitReady(t.Context(), -1, done)
359+ if !joined || err == nil {
360+ t.Fatal(joined, err)
361+ }
362+ })
363+ t.Run("cancel", func(t *testing.T) {
364+ ctx, cancel := context.WithCancel(t.Context())
365+ cancel()
366+ joined, err := waitReady(ctx, -1, make(chan error))
367+ if joined || !errors.Is(err, context.Canceled) {
368+ t.Fatal(joined, err)
369+ }
370+ })
371+}
372+
373+// readyScript stands in for Xwayland: after a short delay it creates a marker
374+// file and reports readiness on the -displayfd descriptor when ready is true,
375+// then runs tail.
376+func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) {
377+ t.Helper()
378+ bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready")
379+ script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" +
380+ "sleep 0.3\n"
381+ if ready {
382+ script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n"
383+ }
384+ script += tail + "\n"
385+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
386+ t.Fatal(err)
387+ }
388+ return bin, marker
389+}
390+
391+func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) {
392+ t.Helper()
393+ opts := testOptions(t, "")
394+ opts.Backend = ports.XwaylandBuiltin
395+ var marker string
396+ opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail)
397+ exists := func() bool { _, err := os.Stat(marker); return err == nil }
398+ opts.Attach = func(wlFD int) {
399+ if exists() {
400+ t.Error("Attach ran after readiness")
401+ }
402+ events = append(events, "attach")
403+ unix.Close(wlFD)
404+ }
405+ opts.AttachWM = func(ctx context.Context, wmFD int) func() {
406+ if !exists() {
407+ t.Error("AttachWM ran before readiness")
408+ }
409+ events = append(events, "attachWM")
410+ attachWM(ctx, wmFD)
411+ return func() { events = append(events, "stop") }
412+ }
413+ d, err := Open(opts)
414+ if err != nil {
415+ t.Fatal(err)
416+ }
417+ defer d.Close()
418+ _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()})
419+ return append(events, "returned")
420+}
421+
422+// AttachWM runs only after -displayfd readiness; stop runs before start
423+// returns, whether the process exits or the context ends.
424+func TestBuiltinAttachWMAfterReadiness(t *testing.T) {
425+ for name, tc := range map[string]struct {
426+ tail string
427+ cancel bool
428+ }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} {
429+ t.Run(name, func(t *testing.T) {
430+ ctx, cancel := context.WithCancel(t.Context())
431+ defer cancel()
432+ events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) {
433+ if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil {
434+ t.Errorf("wmFD not open: %v", err)
435+ }
436+ unix.Close(wmFD)
437+ if tc.cancel {
438+ go func() { time.Sleep(200 * time.Millisecond); cancel() }()
439+ }
440+ })
441+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
442+ t.Fatalf("events %s", got)
443+ }
444+ })
445+ }
446+}
447+
448+// A process that exits before reporting readiness never reaches AttachWM.
449+func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) {
450+ events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {})
451+ if got := strings.Join(events, ","); got != "attach,returned" {
452+ t.Fatalf("events %s", got)
453+ }
454+}
455+
456+// The AttachWM context ends when the process dies, so a bootstrap against a
457+// dead Xwayland gives up at once instead of waiting out its handshake.
458+func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) {
459+ ended := make(chan error, 1)
460+ start := time.Now()
461+ events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) {
462+ defer unix.Close(wmFD)
463+ select {
464+ case <-ctx.Done():
465+ ended <- ctx.Err()
466+ case <-time.After(5 * time.Second):
467+ ended <- nil
468+ }
469+ })
470+ if err := <-ended; err == nil {
471+ t.Fatal("AttachWM context did not end with the process")
472+ }
473+ if time.Since(start) > 3*time.Second {
474+ t.Fatalf("took %v", time.Since(start))
475+ }
476+ if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" {
477+ t.Fatalf("events %s", got)
478+ }
479+}
M internal/app/app.go +13 -6
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "io"
8+ "os/exec"
89 "time"
910
1011 "github.com/bnema/neferwl/internal/adapters/logging"
@@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) {
188189 }
189190 }
190191
191-// openXwayland reserves an X11 display for xwayland-satellite, or returns
192+// openXwayland reserves an X11 display for the selected backend, or returns
192193 // nil when X11 is off or unavailable: the session runs without it.
193-func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display {
194- bin := opts.Config.Xwayland
195- if bin == "" || opts.NoXwayland {
194+func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display {
195+ cfg := opts.Config.Xwayland
196+ bin := cfg.Binary
197+ if cfg.Backend == ports.XwaylandOff || opts.NoXwayland {
196198 return nil
197199 }
198- if !xwayland.Supported(ctx, bin) {
200+ if cfg.Backend == ports.XwaylandBuiltin {
201+ if _, err := exec.LookPath(bin); err != nil {
202+ log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled")
203+ return nil
204+ }
205+ } else if !xwayland.Supported(ctx, bin) {
199206 log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled")
200207 return nil
201208 }
202- d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
209+ d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log})
203210 if err != nil {
204211 log.Warn().Err(err).Msg("X11 display disabled")
205212 return nil
M internal/app/cursor.go +18 -0
@@ -138,3 +138,21 @@ func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.Cu
138138 themeCursors.m[key] = out
139139 return out, nil
140140 }
141+
142+// xwaylandSettings is what the built-in XWM publishes to X11 clients: the
143+// XSETTINGS cursor theme and size and the root cursor image, all from the
144+// XCURSOR_* environment. The DPI follows the layout inside the XWM.
145+func xwaylandSettings(cfg ports.XwaylandConfig) ports.XwaylandSettings {
146+ if cfg.Backend != ports.XwaylandBuiltin {
147+ return ports.XwaylandSettings{}
148+ }
149+ size, theme := cursorSize(), os.Getenv("XCURSOR_THEME")
150+ st := ports.XwaylandSettings{XSettings: cfg.XSettings, CursorTheme: theme, CursorSize: size, RootCursor: true}
151+ img, err := xcursor.Load(xcursor.SearchPath(), theme, []string{"default", "left_ptr"}, size)
152+ if err != nil {
153+ return st // the XWM falls back to the X cursor font
154+ }
155+ img = img.ForSize(size, 64)
156+ st.Cursor = ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels}
157+ return st
158+}
M internal/app/session.go +2 -1
@@ -140,6 +140,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
140140 RepeatRate: opts.Config.Keyboard.RepeatRate,
141141 RepeatDelay: opts.Config.Keyboard.RepeatDelay,
142142 LogDamage: logging.Enabled(ctx, "wayland-damage"),
143+ Xwayland: xwaylandSettings(opts.Config.Xwayland),
143144 }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil {
144145 return s, err
145146 }
@@ -152,7 +153,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
152153 s.statePath = statePath
153154 s.childEnv = append(s.childEnv, statefile.Env+"="+statePath)
154155 }
155- if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil {
156+ if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil {
156157 s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name())
157158 }
158159 s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher"))
A internal/app/testdata/x11probe/main.go +676 -0
@@ -0,0 +1,676 @@
1+// Command x11probe is a minimal X11 client for the Xwayland end-to-end test.
2+//
3+// It opens one window filled with solid green, prints "mapped WxH" after the
4+// first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5+// window manager sends WM_DELETE_WINDOW.
6+//
7+// With -override it also maps a magenta override-redirect window after the
8+// main one is mapped (prints "override mapped") and unmaps it after
9+// -override-for (prints "override unmapped"). With -override-dismiss-on-focusout
10+// it unmaps it instead when the main window loses focus (prints "dismissed").
11+//
12+// With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN
13+// -fullscreen-delay after the window is mapped (0: right at the map) and
14+// prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to
15+// leave it that long after (prints "fullscreen off WxH"). -keyboard-grab
16+// calls XGrabKeyboard on the window once it is mapped (prints "grab").
17+// -at X,Y creates the window at that root position.
18+//
19+// Four more modes run without a window and exit when done.
20+// -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for
21+// -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above
22+// 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR
23+// aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or
24+// `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI,
25+// Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none".
26+// -root-cursor prints "cursor WxH" of the XFixes cursor image.
27+package main
28+
29+import (
30+ "crypto/sha256"
31+ "encoding/binary"
32+ "flag"
33+ "fmt"
34+ "os"
35+ "strconv"
36+ "strings"
37+ "time"
38+
39+ "golang.org/x/sys/unix"
40+
41+ "bnema.dev/xturbo"
42+ "bnema.dev/xturbo/shape"
43+ "bnema.dev/xturbo/xfixes"
44+ "bnema.dev/xturbo/xproto"
45+)
46+
47+const (
48+ green = 0x00ff00
49+ magenta = 0xff00ff
50+)
51+
52+func main() {
53+ if err := run(); err != nil {
54+ fmt.Fprintln(os.Stderr, "x11probe:", err)
55+ os.Exit(1)
56+ }
57+}
58+
59+func run() error {
60+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
61+ title := flag.String("title", "probe", "_NET_WM_NAME")
62+ size := flag.String("size", "300x200", "window size WxH")
63+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
64+ override := flag.Bool("override", false, "map a magenta override-redirect window after the main one")
65+ overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates")
66+ overrideSize := flag.String("override-size", "120x80", "override window size WxH")
67+ overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)")
68+ overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip")
69+ overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape")
70+ dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus")
71+ fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped")
72+ fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request")
73+ fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)")
74+ keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped")
75+ at := flag.String("at", "0,0", "window position X,Y in root coordinates")
76+ copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window")
77+ copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY")
78+ copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection")
79+ paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window")
80+ xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window")
81+ rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window")
82+ flag.Parse()
83+ if *copyText != "" || *paste != "" || *xsettings || *rootCursor {
84+ return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor)
85+ }
86+ atX, atY, okAt := strings.Cut(*at, ",")
87+ if !okAt {
88+ return fmt.Errorf("bad -at %q", *at)
89+ }
90+ w, h, ok := parseSize(*size)
91+ if !ok {
92+ return fmt.Errorf("bad -size %q", *size)
93+ }
94+
95+ c, err := xturbo.Dial("") // $DISPLAY
96+ if err != nil {
97+ return err
98+ }
99+ defer c.Close()
100+
101+ var atoms [9]uint32
102+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING",
103+ "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil {
104+ return err
105+ }
106+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
107+ typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6]
108+ netWMState, netWMFullscreen := atoms[7], atoms[8]
109+
110+ b := c.Buf()
111+ xwin, err := c.NewXID()
112+ if err != nil {
113+ return err
114+ }
115+ xgc, err := c.NewXID()
116+ if err != nil {
117+ return err
118+ }
119+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
120+
121+ var v xproto.CreateWindowValues
122+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange)
123+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
124+
125+ wmClass := []byte("x11probe\x00" + *class + "\x00")
126+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
127+ name := []byte(*title)
128+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
129+ var del [4]byte
130+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
131+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
132+
133+ var gv xproto.CreateGCValues
134+ gv.SetForeground(green)
135+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
136+ if err := c.Flush(); err != nil {
137+ return err
138+ }
139+ time.Sleep(*sleep)
140+ if *at != "0,0" {
141+ // Like games, ask for the rect before the map (a ConfigureRequest the
142+ // window manager sees as the output hint).
143+ var cv xproto.ConfigureWindowValues
144+ cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h))
145+ xproto.ConfigureWindow(b, win, &cv)
146+ }
147+ xproto.MapWindow(b, win)
148+
149+ // The override-redirect window is created once the main window is mapped.
150+ var ovWin xproto.Window
151+ var ovAt, ovSz [2]int
152+ if *override {
153+ var ok1, ok2 bool
154+ x, y, found := strings.Cut(*overrideAt, ",")
155+ ovAt[0], ovAt[1] = atoi(x), atoi(y)
156+ ok1 = found
157+ ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize)
158+ if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") {
159+ return fmt.Errorf("bad -override-at/-override-size/-override-type")
160+ }
161+ }
162+ var ovDeadline time.Time // zero: no timed unmap pending
163+ unmapOverride := func(msg string) {
164+ if ovWin == 0 {
165+ return
166+ }
167+ xproto.UnmapWindow(b, ovWin)
168+ ovWin = 0
169+ ovDeadline = time.Time{}
170+ fmt.Println(msg)
171+ }
172+ mapOverride := func() error {
173+ id, err := c.NewXID()
174+ if err != nil {
175+ return err
176+ }
177+ ovWin = xproto.Window(id)
178+ var ov xproto.CreateWindowValues
179+ ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true)
180+ 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)
181+ typ := typeMenu
182+ if *overrideType == "tooltip" {
183+ typ = typeTooltip
184+ }
185+ var tb [4]byte
186+ binary.LittleEndian.PutUint32(tb[:], typ)
187+ xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:])
188+ if *overrideShape {
189+ shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil)
190+ }
191+ xproto.MapWindow(b, ovWin)
192+ fmt.Println("override mapped")
193+ if *overrideFor > 0 {
194+ ovDeadline = time.Now().Add(*overrideFor)
195+ }
196+ return nil
197+ }
198+ var ovErr error
199+
200+ // Fullscreen requests go to the root window as a client message, as ICCCM
201+ // and EWMH say for a mapped window.
202+ fsMsg := func(action uint32) {
203+ var ev [32]byte
204+ ev[0] = byte(xproto.KindClientMessage)
205+ ev[1] = 32
206+ binary.LittleEndian.PutUint32(ev[4:], uint32(win))
207+ binary.LittleEndian.PutUint32(ev[8:], netWMState)
208+ binary.LittleEndian.PutUint32(ev[12:], action)
209+ binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen)
210+ xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev)
211+ }
212+ var fsAt, fsOffAt time.Time // zero: nothing pending
213+
214+ mapped, done, isFS := false, false, false
215+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
216+ hd := &xturbo.Handler{
217+ OnEvent: func(ev *xturbo.Event) {
218+ switch ev.Kind {
219+ case xproto.KindExpose:
220+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
221+ case xproto.KindConfigureNotify:
222+ e := xproto.ConfigureNotifyOf(ev)
223+ if e.Window() != win {
224+ break
225+ }
226+ cw, ch = e.Width(), e.Height()
227+ if !mapped {
228+ mapped = true
229+ fmt.Printf("mapped %dx%d\n", cw, ch)
230+ if *override {
231+ ovErr = mapOverride()
232+ }
233+ if *fullscreen {
234+ fsAt = time.Now().Add(*fullscreenDelay)
235+ }
236+ if *keyboardGrab {
237+ xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync)
238+ fmt.Println("grab")
239+ }
240+ }
241+ case xproto.KindPropertyNotify:
242+ if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) {
243+ seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64)
244+ if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
245+ ovErr = err
246+ }
247+ }
248+ case xproto.KindFocusIn:
249+ fmt.Println("focus")
250+ case xproto.KindFocusOut:
251+ if *dismiss && xproto.FocusOutOf(ev).Event() == win {
252+ unmapOverride("dismissed")
253+ }
254+ case xproto.KindClientMessage:
255+ e := xproto.ClientMessageOf(ev)
256+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
257+ done = true
258+ }
259+ }
260+ },
261+ OnReply: func(r *xturbo.Reply) {
262+ rep, ok := xproto.ParseGetPropertyReply(r.Raw)
263+ if !ok {
264+ return
265+ }
266+ on := false
267+ for v := rep.Value(); len(v) >= 4; v = v[4:] {
268+ on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen
269+ }
270+ // Report the state the window manager published, once per change.
271+ switch {
272+ case on && !isFS:
273+ fmt.Printf("fullscreen %dx%d\n", cw, ch)
274+ case !on && isFS:
275+ fmt.Printf("fullscreen off %dx%d\n", cw, ch)
276+ }
277+ isFS = on
278+ },
279+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
280+ }
281+ for !done {
282+ events := int16(unix.POLLIN)
283+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
284+ events |= unix.POLLOUT
285+ } else if err != nil {
286+ return err
287+ }
288+ timeout := -1
289+ for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} {
290+ if !d.IsZero() {
291+ ms := max(int(time.Until(d).Milliseconds())+1, 0)
292+ if timeout < 0 || ms < timeout {
293+ timeout = ms
294+ }
295+ }
296+ }
297+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
298+ if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR {
299+ return err
300+ }
301+ if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) {
302+ unmapOverride("override unmapped")
303+ }
304+ if !fsAt.IsZero() && !time.Now().Before(fsAt) {
305+ fsAt = time.Time{}
306+ fsMsg(1)
307+ if *fullscreenOff > 0 {
308+ fsOffAt = time.Now().Add(*fullscreenOff)
309+ }
310+ }
311+ if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) {
312+ fsOffAt = time.Time{}
313+ fsMsg(0)
314+ }
315+ if err := c.Dispatch(hd); err != nil {
316+ return err
317+ }
318+ if ovErr != nil {
319+ return ovErr
320+ }
321+ }
322+ return nil
323+}
324+
325+func parseSize(s string) (w, h int, ok bool) {
326+ ws, hs, found := strings.Cut(s, "x")
327+ if !found {
328+ return 0, 0, false
329+ }
330+ w, err1 := strconv.Atoi(ws)
331+ h, err2 := strconv.Atoi(hs)
332+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
333+}
334+
335+func atoi(s string) int {
336+ n, _ := strconv.Atoi(s)
337+ return n
338+}
339+
340+// tool is the connection of the windowless modes. Events read while waiting
341+// for a reply are queued, so the code reads linearly.
342+type tool struct {
343+ c *xturbo.Conn
344+ b *xturbo.Buf
345+ win xproto.Window
346+ q []xturbo.Event
347+ reply []byte
348+ h xturbo.Handler
349+ atoms map[string]xproto.Atom
350+}
351+
352+const chunk = 65536
353+
354+func newTool() (*tool, error) {
355+ c, err := xturbo.Dial("")
356+ if err != nil {
357+ return nil, err
358+ }
359+ t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}}
360+ t.h = xturbo.Handler{
361+ OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) },
362+ OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) },
363+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
364+ }
365+ names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL",
366+ "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"}
367+ ids := make([]uint32, len(names))
368+ if err := c.InternAtoms(names, false, ids); err != nil {
369+ return nil, err
370+ }
371+ for i, n := range names {
372+ t.atoms[n] = xproto.Atom(ids[i])
373+ }
374+ xid, err := c.NewXID()
375+ if err != nil {
376+ return nil, err
377+ }
378+ t.win = xproto.Window(xid)
379+ var v xproto.CreateWindowValues
380+ v.SetEventMask(xproto.EventMaskPropertyChange)
381+ xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v)
382+ return t, t.c.Sync(&t.h)
383+}
384+
385+// rpc sends the queued requests and returns the reply of seq.
386+func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) {
387+ t.reply = t.reply[:0]
388+ if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil {
389+ return nil, err
390+ }
391+ if err := t.c.Sync(&t.h); err != nil {
392+ return nil, err
393+ }
394+ return t.reply, nil
395+}
396+
397+// next returns the next event, or false at the deadline.
398+func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) {
399+ for len(t.q) == 0 {
400+ events := int16(unix.POLLIN)
401+ if err := t.c.Flush(); err == xturbo.ErrWouldBlock {
402+ events |= unix.POLLOUT
403+ } else if err != nil {
404+ return xturbo.Event{}, false, err
405+ }
406+ ms := int(time.Until(deadline).Milliseconds()) + 1
407+ if ms <= 0 {
408+ return xturbo.Event{}, false, nil
409+ }
410+ fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}}
411+ if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR {
412+ return xturbo.Event{}, false, err
413+ }
414+ if err := t.c.Dispatch(&t.h); err != nil {
415+ return xturbo.Event{}, false, err
416+ }
417+ }
418+ ev := t.q[0]
419+ t.q = t.q[1:]
420+ return ev, true, nil
421+}
422+
423+func (t *tool) selAtom(name string) (xproto.Atom, error) {
424+ switch name {
425+ case "CLIPBOARD", "PRIMARY":
426+ return t.atoms[name], nil
427+ }
428+ return 0, fmt.Errorf("bad selection %q", name)
429+}
430+
431+func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error {
432+ t, err := newTool()
433+ if err != nil {
434+ return err
435+ }
436+ defer t.c.Close()
437+ switch {
438+ case copyText != "":
439+ return t.copyMode(copyText, copySel, copyFor)
440+ case paste != "":
441+ return t.pasteMode(paste)
442+ case xsettings:
443+ return t.xsettingsMode()
444+ }
445+ return t.cursorMode()
446+}
447+
448+func (t *tool) copyMode(text, selName string, dur time.Duration) error {
449+ data := []byte(text)
450+ if f, ok := strings.CutPrefix(text, "@"); ok {
451+ var err error
452+ if data, err = os.ReadFile(f); err != nil {
453+ return err
454+ }
455+ }
456+ sel, err := t.selAtom(selName)
457+ if err != nil {
458+ return err
459+ }
460+ xproto.SetSelectionOwner(t.b, t.win, sel, 0)
461+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel))
462+ if err != nil {
463+ return err
464+ }
465+ if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win {
466+ return fmt.Errorf("selection %s not owned", selName)
467+ }
468+ fmt.Println("copy owned")
469+ var (
470+ incrReq xproto.Window // requestor of the running INCR transfer
471+ incrProp, incrTgt xproto.Atom
472+ incrOff int
473+ incrLive bool
474+ notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) {
475+ var ev [32]byte
476+ d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop}
477+ d.Put(&ev)
478+ xproto.SendEvent(t.b, false, req, 0, &ev)
479+ }
480+ )
481+ deadline := time.Now().Add(dur)
482+ for {
483+ ev, ok, err := t.next(deadline)
484+ if err != nil || !ok {
485+ return err
486+ }
487+ switch ev.Kind {
488+ case xproto.KindSelectionClear:
489+ fmt.Println("copy lost")
490+ return nil
491+ case xproto.KindSelectionRequest:
492+ e := xproto.SelectionRequestOf(&ev)
493+ req, prop, tgt := e.Requestor(), e.Property(), e.Target()
494+ if prop == 0 {
495+ prop = tgt
496+ }
497+ switch tgt {
498+ case t.atoms["TARGETS"]:
499+ var l [16]byte
500+ for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} {
501+ binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n]))
502+ }
503+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:])
504+ case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]:
505+ if len(data) <= chunk {
506+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data)
507+ break
508+ }
509+ var cv xproto.ChangeWindowAttributesValues
510+ cv.SetEventMask(xproto.EventMaskPropertyChange)
511+ xproto.ChangeWindowAttributes(t.b, req, &cv)
512+ var n [4]byte
513+ binary.LittleEndian.PutUint32(n[:], uint32(len(data)))
514+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:])
515+ incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true
516+ default:
517+ prop = 0
518+ }
519+ notify(req, e.Selection(), tgt, prop, e.Time())
520+ case xproto.KindPropertyNotify:
521+ e := xproto.PropertyNotifyOf(&ev)
522+ if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete {
523+ break
524+ }
525+ end := min(incrOff+chunk, len(data))
526+ xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end])
527+ if end == incrOff {
528+ incrLive = false
529+ }
530+ incrOff = end
531+ }
532+ }
533+}
534+
535+func (t *tool) pasteMode(selName string) error {
536+ sel, err := t.selAtom(selName)
537+ if err != nil {
538+ return err
539+ }
540+ prop := t.atoms["_PROBE_SEL"]
541+ xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0)
542+ deadline := time.Now().Add(10 * time.Second)
543+ var got []byte
544+ incr := false
545+ get := func() (value []byte, typ xproto.Atom, err error) {
546+ raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22))
547+ if err != nil {
548+ return nil, 0, err
549+ }
550+ r, ok := xproto.ParseGetPropertyReply(raw)
551+ if !ok {
552+ return nil, 0, fmt.Errorf("bad GetProperty reply")
553+ }
554+ return append([]byte(nil), r.Value()...), r.Type(), nil
555+ }
556+ for {
557+ ev, ok, err := t.next(deadline)
558+ if err != nil {
559+ return err
560+ }
561+ if !ok {
562+ return fmt.Errorf("paste timed out")
563+ }
564+ switch ev.Kind {
565+ case xproto.KindSelectionNotify:
566+ if xproto.SelectionNotifyOf(&ev).Property() == 0 {
567+ fmt.Println("paste none")
568+ return nil
569+ }
570+ v, typ, err := get()
571+ if err != nil {
572+ return err
573+ }
574+ if typ != t.atoms["INCR"] {
575+ return printPaste(v)
576+ }
577+ incr = true // reading and deleting the INCR property starts the transfer
578+ case xproto.KindPropertyNotify:
579+ e := xproto.PropertyNotifyOf(&ev)
580+ if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue {
581+ break
582+ }
583+ v, _, err := get()
584+ if err != nil {
585+ return err
586+ }
587+ if len(v) == 0 {
588+ return printPaste(got)
589+ }
590+ got = append(got, v...)
591+ }
592+ deadline = time.Now().Add(10 * time.Second)
593+ }
594+}
595+
596+func printPaste(v []byte) error {
597+ if len(v) <= 4096 {
598+ fmt.Printf("paste %q\n", v)
599+ } else {
600+ fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v))
601+ }
602+ return nil
603+}
604+
605+func (t *tool) xsettingsMode() error {
606+ raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"]))
607+ if err != nil {
608+ return err
609+ }
610+ r, ok := xproto.ParseGetSelectionOwnerReply(raw)
611+ if !ok || r.Owner() == 0 {
612+ fmt.Println("xsettings none")
613+ return nil
614+ }
615+ raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16))
616+ if err != nil {
617+ return err
618+ }
619+ pr, ok := xproto.ParseGetPropertyReply(raw)
620+ if !ok || len(pr.Value()) < 12 {
621+ fmt.Println("xsettings none")
622+ return nil
623+ }
624+ v := pr.Value()
625+ if v[0] != 0 {
626+ return fmt.Errorf("xsettings: big-endian data")
627+ }
628+ le := binary.LittleEndian
629+ serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:]))
630+ vals := map[string]string{}
631+ p := v[12:]
632+ pad := func(n int) int { return (n + 3) &^ 3 }
633+ for i := 0; i < n; i++ {
634+ if len(p) < 4 {
635+ return fmt.Errorf("xsettings: truncated")
636+ }
637+ typ, nl := p[0], int(le.Uint16(p[2:]))
638+ if len(p) < 4+pad(nl)+4 {
639+ return fmt.Errorf("xsettings: truncated")
640+ }
641+ name := string(p[4 : 4+nl])
642+ p = p[4+pad(nl)+4:] // name, last-change-serial
643+ switch typ {
644+ case 0:
645+ vals[name] = strconv.Itoa(int(int32(le.Uint32(p))))
646+ p = p[4:]
647+ case 1:
648+ sl := int(le.Uint32(p))
649+ vals[name] = string(p[4 : 4+sl])
650+ p = p[4+pad(sl):]
651+ case 2:
652+ p = p[8:]
653+ default:
654+ return fmt.Errorf("xsettings: type %d", typ)
655+ }
656+ }
657+ fmt.Printf("xsettings serial=%d Xft/DPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n",
658+ serial, vals["Xft/DPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"])
659+ return nil
660+}
661+
662+func (t *tool) cursorMode() error {
663+ if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil {
664+ return err
665+ }
666+ raw, err := t.rpc(xfixes.GetCursorImage(t.b))
667+ if err != nil {
668+ return err
669+ }
670+ r, ok := xfixes.ParseGetCursorImageReply(raw)
671+ if !ok {
672+ return fmt.Errorf("bad GetCursorImage reply")
673+ }
674+ fmt.Printf("cursor %dx%d\n", r.Width(), r.Height())
675+ return nil
676+}
A internal/app/xwayland_test.go +778 -0
@@ -0,0 +1,778 @@
1+//go:build xwayland
2+
3+// End-to-end test of the built-in XWM: a real Xwayland and a real X11 client
4+// (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on
5+// PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with
6+// `make e2e-xwayland`.
7+package app
8+
9+import (
10+ "context"
11+ "crypto/sha256"
12+ "fmt"
13+ "image"
14+ "image/png"
15+ "io"
16+ "os"
17+ "os/exec"
18+ "path/filepath"
19+ "regexp"
20+ "strconv"
21+ "strings"
22+ "sync"
23+ "testing"
24+ "time"
25+
26+ "github.com/bnema/zerowrap"
27+
28+ "github.com/bnema/neferwl/internal/adapters/config"
29+ "github.com/bnema/neferwl/internal/adapters/statefile"
30+ "github.com/bnema/neferwl/internal/ports"
31+)
32+
33+const (
34+ xwaylandTimeout = 10 * time.Second
35+ xwaylandW, xwaylandH = 640, 360
36+)
37+
38+var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
39+
40+// probe is a running x11probe.
41+type probe struct {
42+ cmd *exec.Cmd
43+ out *lockedBuffer
44+ waited chan error
45+}
46+
47+// xwaylandEnv is a running headless compositor with the built-in XWM.
48+type xwaylandEnv struct {
49+ bin, display, statePath, shots string
50+ script *io.PipeWriter
51+}
52+
53+// startXwayland runs the compositor on one headless output and waits for its
54+// X11 display. It skips the test without Xwayland and stops everything when
55+// the test ends.
56+func startXwayland(t *testing.T) *xwaylandEnv {
57+ t.Helper()
58+ return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil)
59+}
60+
61+// startXwaylandWith is startXwayland with the given headless outputs, left to
62+// right, and an optional change to the default config.
63+func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv {
64+ t.Helper()
65+ if _, err := exec.LookPath("Xwayland"); err != nil {
66+ t.Skip("Xwayland not on PATH")
67+ }
68+ // The compositor must not read or write the live session's state.
69+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
70+ if v, ok := os.LookupEnv(k); ok {
71+ os.Unsetenv(k)
72+ t.Cleanup(func() { os.Setenv(k, v) })
73+ }
74+ }
75+ runtime := t.TempDir()
76+ t.Setenv("XDG_RUNTIME_DIR", runtime)
77+ t.Setenv("XDG_STATE_HOME", t.TempDir())
78+ shots := t.TempDir()
79+ bin := buildProbe(t)
80+
81+ script, scriptW := io.Pipe()
82+ logs := &lockedBuffer{}
83+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
84+ cfg := config.Defaults()
85+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}
86+ if mutate != nil {
87+ mutate(&cfg)
88+ }
89+ done := make(chan error, 1)
90+ go func() {
91+ done <- Run(ctx, Options{
92+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
93+ ScreenshotDir: shots, Sizes: sizes,
94+ })
95+ }()
96+ t.Cleanup(func() {
97+ if t.Failed() {
98+ t.Logf("compositor log:\n%s", logs.String())
99+ }
100+ cancel()
101+ _ = scriptW.Close()
102+ select {
103+ case err := <-done:
104+ if err != nil {
105+ t.Errorf("Run: %v", err)
106+ }
107+ case <-time.After(5 * time.Second):
108+ t.Error("Run did not stop")
109+ }
110+ })
111+
112+ var display string
113+ until(t, "X11 display in the log", func() bool {
114+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
115+ display = m[1]
116+ return true
117+ }
118+ return false
119+ })
120+ statePath, err := statefile.Path(runtime, "wayland-1")
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
125+}
126+
127+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
128+// up in the state with its class and pid, is drawn, tiles next to a second
129+// one, receives focus and is closed by the close-window bind.
130+func TestXwaylandProbe(t *testing.T) {
131+ e := startXwayland(t)
132+ bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
133+ first := startProbe(t, bin, display, "-title", "one")
134+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
135+
136+ var st statefile.State
137+ until(t, "one visible X11Probe window", func() bool {
138+ st = readState(statePath)
139+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
140+ })
141+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
142+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
143+ }
144+
145+ // The client draws green and the compositor shows it.
146+ id := st.Windows[0].ID
147+ until(t, "green window in latest.png", func() bool {
148+ r, ok := tileRect(readState(statePath), id)
149+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
150+ })
151+
152+ // Focus round trip: the compositor focused the window, the client saw it.
153+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
154+
155+ // A second probe tiles beside the first.
156+ second := startProbe(t, bin, display, "-title", "two")
157+ until(t, "two tiled windows", func() bool {
158+ st = readState(statePath)
159+ if len(st.Windows) != 2 {
160+ return false
161+ }
162+ for _, w := range st.Windows {
163+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
164+ return false
165+ }
166+ }
167+ return true
168+ })
169+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
170+
171+ // The close-window bind asks the focused (second) probe to close.
172+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
173+ t.Fatal(err)
174+ }
175+ select {
176+ case err := <-second.waited:
177+ if err != nil {
178+ t.Errorf("probe two exited with %v, want 0", err)
179+ }
180+ case <-time.After(2 * time.Second):
181+ t.Fatal("probe two did not exit within 2s of Super+q")
182+ }
183+ until(t, "one window left", func() bool {
184+ st = readState(statePath)
185+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
186+ })
187+ select {
188+ case err := <-first.waited:
189+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
190+ default:
191+ }
192+}
193+
194+// buildProbe builds testdata/x11probe.
195+func buildProbe(t *testing.T) string {
196+ t.Helper()
197+ bin := filepath.Join(t.TempDir(), "x11probe")
198+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
199+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
200+ if out, err := build.CombinedOutput(); err != nil {
201+ t.Fatalf("build x11probe: %v: %s", err, out)
202+ }
203+ return bin
204+}
205+
206+// startProbe starts x11probe on display, with X11 as its only display
207+// server, and stops it when the test ends.
208+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
209+ t.Helper()
210+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
211+ p.cmd = exec.Command(bin, args...)
212+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
213+ p.cmd.Stdout = p.out
214+ stderr := &lockedBuffer{}
215+ p.cmd.Stderr = stderr
216+ if err := p.cmd.Start(); err != nil {
217+ t.Fatal(err)
218+ }
219+ var exited sync.Once
220+ go func() { p.waited <- p.cmd.Wait() }()
221+ t.Cleanup(func() {
222+ exited.Do(func() {
223+ _ = p.cmd.Process.Kill()
224+ select {
225+ case <-p.waited:
226+ case <-time.After(2 * time.Second):
227+ }
228+ })
229+ if s := stderr.String(); s != "" {
230+ t.Logf("x11probe stderr: %s", s)
231+ }
232+ })
233+ return p
234+}
235+
236+func envWithout(keys ...string) []string {
237+ var env []string
238+next:
239+ for _, kv := range os.Environ() {
240+ for _, k := range keys {
241+ if strings.HasPrefix(kv, k+"=") {
242+ continue next
243+ }
244+ }
245+ env = append(env, kv)
246+ }
247+ return env
248+}
249+
250+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
251+func until(t *testing.T, what string, cond func() bool) {
252+ t.Helper()
253+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
254+ if time.Now().After(deadline) {
255+ t.Fatalf("timeout waiting for %s", what)
256+ }
257+ }
258+}
259+
260+// readState returns the current state file, or an empty state while it does
261+// not exist or is being replaced.
262+func readState(path string) statefile.State {
263+ st, err := statefile.Read(path)
264+ if err != nil {
265+ return statefile.State{}
266+ }
267+ return st
268+}
269+
270+// tileRect finds the rectangle of window id in the tiled columns.
271+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
272+ for _, ws := range st.Workspaces {
273+ for _, c := range ws.Columns {
274+ for _, w := range c.Windows {
275+ if w.ID == id {
276+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
277+ }
278+ }
279+ }
280+ }
281+ return image.Rectangle{}, false
282+}
283+
284+// greenShare is the fraction of pixels of r in the PNG at path that are
285+// clearly green; 0 when the image cannot be read yet.
286+func greenShare(path string, r image.Rectangle) float64 {
287+ f, err := os.Open(path)
288+ if err != nil {
289+ return 0
290+ }
291+ defer f.Close()
292+ img, err := png.Decode(f)
293+ if err != nil {
294+ return 0
295+ }
296+ r = r.Intersect(img.Bounds())
297+ if r.Empty() {
298+ return 0
299+ }
300+ n := 0
301+ for y := r.Min.Y; y < r.Max.Y; y++ {
302+ for x := r.Min.X; x < r.Max.X; x++ {
303+ cr, cg, cb, _ := img.At(x, y).RGBA()
304+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
305+ n++
306+ }
307+ }
308+ }
309+ return float64(n) / float64(r.Dx()*r.Dy())
310+}
311+
312+// magentaShare is the fraction of pixels of r in the PNG at path that are
313+// clearly magenta; 0 when the image cannot be read yet.
314+func magentaShare(path string, r image.Rectangle) float64 {
315+ f, err := os.Open(path)
316+ if err != nil {
317+ return 0
318+ }
319+ defer f.Close()
320+ img, err := png.Decode(f)
321+ if err != nil {
322+ return 0
323+ }
324+ r = r.Intersect(img.Bounds())
325+ if r.Empty() {
326+ return 0
327+ }
328+ n := 0
329+ for y := r.Min.Y; y < r.Max.Y; y++ {
330+ for x := r.Min.X; x < r.Max.X; x++ {
331+ cr, cg, cb, _ := img.At(x, y).RGBA()
332+ if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
333+ n++
334+ }
335+ }
336+ }
337+ return float64(n) / float64(r.Dx()*r.Dy())
338+}
339+
340+// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
341+// is drawn above the toplevel at its X position, is not a window in the
342+// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
343+func TestXwaylandOverride(t *testing.T) {
344+ e := startXwayland(t)
345+ // Probe unmaps the menu 3 s after mapping it; the first checks run well
346+ // inside that window.
347+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
348+ "-override-size", "120x80", "-override-for", "3s")
349+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
350+ shot := filepath.Join(e.shots, "latest.png")
351+ box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
352+ until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
353+
354+ var st statefile.State
355+ until(t, "focused X11Probe toplevel", func() bool {
356+ st = readState(e.statePath)
357+ return len(st.Windows) >= 1 && st.Window != nil
358+ })
359+ if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
360+ t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
361+ }
362+ id := st.Windows[0].ID
363+ if st.Window.ID != id {
364+ t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
365+ }
366+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
367+
368+ // The menu is above the toplevel: its box is magenta, not green.
369+ if g := greenShare(shot, box); g > 0.1 {
370+ t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
371+ }
372+ until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
373+ until(t, "menu gone from latest.png", func() bool {
374+ return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
375+ })
376+ if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
377+ t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
378+ }
379+}
380+
381+// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
382+// when core focuses another window, which is how toolkits close their menus.
383+func TestXwaylandOverrideDismiss(t *testing.T) {
384+ e := startXwayland(t)
385+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
386+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
387+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
388+ // A second X11 window takes the focus away from the menu owner.
389+ startProbe(t, e.bin, e.display, "-title", "other")
390+ until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
391+ box := image.Rect(100, 50, 220, 130)
392+ until(t, "menu gone from latest.png", func() bool {
393+ return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
394+ })
395+}
396+
397+// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
398+// and still not a window.
399+func TestXwaylandOverrideShape(t *testing.T) {
400+ e := startXwayland(t)
401+ p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
402+ until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
403+ until(t, "magenta tooltip in latest.png", func() bool {
404+ return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
405+ })
406+ until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
407+}
408+
409+// fullOutput is the share of the output screenshot at path that is green.
410+func fullOutput(path string) float64 {
411+ return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH))
412+}
413+
414+// windowOf returns the state window with the given pid.
415+func windowOf(st statefile.State, pid int) (statefile.Window, bool) {
416+ for _, w := range st.Windows {
417+ if w.PID == pid {
418+ return w, true
419+ }
420+ }
421+ return statefile.Window{}, false
422+}
423+
424+// TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the
425+// output, the toggle bind leaves it, and a request made right at the map is
426+// ignored (the grace, current policy).
427+func TestXwaylandFullscreen(t *testing.T) {
428+ // Gaps make a tile visibly smaller than the output.
429+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
430+ shot := filepath.Join(e.shots, "latest.png")
431+ corner := image.Rect(0, 0, 4, 4)
432+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s")
433+ until(t, "tiled with a gap", func() bool {
434+ return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0
435+ })
436+ until(t, "probe fullscreen at output size", func() bool {
437+ return strings.Contains(p.out.String(), "fullscreen 640x360")
438+ })
439+ until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
440+
441+ // The toggle bind (cmd+shift+f) leaves fullscreen.
442+ if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil {
443+ t.Fatal(err)
444+ }
445+ until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
446+ until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 })
447+}
448+
449+// TestXwaylandFullscreenOff: the client leaves fullscreen itself.
450+func TestXwaylandFullscreenOff(t *testing.T) {
451+ e := startXwayland(t)
452+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s")
453+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
454+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
455+ until(t, "tiled", func() bool {
456+ st := readState(e.statePath)
457+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
458+ })
459+}
460+
461+// TestXwaylandFullscreenGrace: a request made at once after the map stays
462+// tiled (fullscreenGrace in core).
463+func TestXwaylandFullscreenGrace(t *testing.T) {
464+ e := startXwayland(t)
465+ p := startProbe(t, e.bin, e.display, "-fullscreen")
466+ until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
467+ until(t, "tiled window", func() bool {
468+ st := readState(e.statePath)
469+ return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
470+ })
471+ // Stay inside the grace, then check it is still not fullscreen.
472+ time.Sleep(500 * time.Millisecond)
473+ st := readState(e.statePath)
474+ if _, ok := tileRect(st, st.Windows[0].ID); !ok {
475+ t.Error("window went fullscreen inside the grace")
476+ }
477+ if strings.Contains(p.out.String(), "fullscreen ") {
478+ t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String())
479+ }
480+}
481+
482+// TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the
483+// keyboard grab the close bind does not fire; after it leaves fullscreen the
484+// binds work again.
485+func TestXwaylandKeyboardGrab(t *testing.T) {
486+ e := startXwayland(t)
487+ p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab")
488+ until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
489+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
490+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
491+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
492+ t.Fatal(err)
493+ }
494+ select {
495+ case err := <-p.waited:
496+ t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err)
497+ case <-time.After(2 * time.Second):
498+ }
499+
500+ // The probe leaves fullscreen itself; its grab stays, but the binds work again.
501+ until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
502+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
503+ t.Fatal(err)
504+ }
505+ select {
506+ case err := <-p.waited:
507+ if err != nil {
508+ t.Errorf("probe exited with %v, want 0", err)
509+ }
510+ case <-time.After(2 * time.Second):
511+ t.Fatal("Super+q did not close the client after it left fullscreen")
512+ }
513+}
514+
515+// TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock
516+// the binds.
517+func TestXwaylandKeyboardGrabWindowed(t *testing.T) {
518+ e := startXwayland(t)
519+ p := startProbe(t, e.bin, e.display, "-keyboard-grab")
520+ until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
521+ until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
522+ if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
523+ t.Fatal(err)
524+ }
525+ select {
526+ case err := <-p.waited:
527+ if err != nil {
528+ t.Errorf("probe exited with %v, want 0", err)
529+ }
530+ case <-time.After(2 * time.Second):
531+ t.Fatal("Super+q did not close a windowed client that holds a keyboard grab")
532+ }
533+}
534+
535+// TestXwaylandOutputHint: with two outputs a window created at the origin of
536+// the second one opens on it, and a fullscreen request fills that output.
537+func TestXwaylandOutputHint(t *testing.T) {
538+ sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}}
539+ e := startXwaylandWith(t, sizes, nil)
540+ p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms")
541+ var w statefile.Window
542+ until(t, "window on HEADLESS-2", func() bool {
543+ var ok bool
544+ w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid)
545+ if ok && w.Output != "HEADLESS-2" {
546+ t.Logf("window output = %s", w.Output)
547+ }
548+ return ok && w.Output == "HEADLESS-2"
549+ })
550+ until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
551+ until(t, "HEADLESS-2 all green", func() bool {
552+ return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99
553+ })
554+ if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 {
555+ t.Errorf("HEADLESS-1 green share = %.2f, want empty", g)
556+ }
557+}
558+
559+// TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window
560+// the way it matches a Wayland app ID (steam_app_<id> from Proton).
561+func TestXwaylandClassRule(t *testing.T) {
562+ yes := true
563+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
564+ c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes})
565+ })
566+ startProbe(t, e.bin, e.display, "-class", "steam_app_413150")
567+ until(t, "floating steam_app window", func() bool {
568+ st := readState(e.statePath)
569+ return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating
570+ })
571+ startProbe(t, e.bin, e.display, "-class", "Other")
572+ until(t, "other class tiled", func() bool {
573+ for _, w := range readState(e.statePath).Windows {
574+ if w.AppID == "Other" {
575+ return !w.Floating
576+ }
577+ }
578+ return false
579+ })
580+}
581+
582+// waylandEnv is the environment of a Wayland client of the headless
583+// compositor: its socket, not the user's session.
584+func waylandEnv() []string {
585+ return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1")
586+}
587+
588+// runProbe runs a one-shot x11probe mode and returns its stdout.
589+func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string {
590+ t.Helper()
591+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
592+ defer cancel()
593+ cmd := exec.CommandContext(ctx, e.bin, args...)
594+ cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display)
595+ out, err := cmd.Output()
596+ if err != nil {
597+ t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out)
598+ }
599+ return strings.TrimSpace(string(out))
600+}
601+
602+// needWlClipboard skips the test without wl-copy and wl-paste.
603+func needWlClipboard(t *testing.T) {
604+ t.Helper()
605+ for _, c := range []string{"wl-copy", "wl-paste"} {
606+ if _, err := exec.LookPath(c); err != nil {
607+ t.Skipf("%s not on PATH (wl-clipboard)", c)
608+ }
609+ }
610+}
611+
612+// wlCopy starts wl-copy --foreground serving data on the headless
613+// compositor's seat and stops it when the test ends.
614+func wlCopy(t *testing.T, data string, args ...string) {
615+ t.Helper()
616+ cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...)
617+ cmd.Env = waylandEnv()
618+ cmd.Stdin = strings.NewReader(data)
619+ stderr := &lockedBuffer{}
620+ cmd.Stderr = stderr
621+ if err := cmd.Start(); err != nil {
622+ t.Fatal(err)
623+ }
624+ waited := make(chan struct{})
625+ go func() { _ = cmd.Wait(); close(waited) }()
626+ t.Cleanup(func() {
627+ _ = cmd.Process.Kill()
628+ <-waited
629+ if s := stderr.String(); s != "" {
630+ t.Logf("wl-copy stderr: %s", s)
631+ }
632+ })
633+}
634+
635+// wlPaste returns what wl-paste prints, and whether it succeeded.
636+func wlPaste(args ...string) (string, bool) {
637+ ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
638+ defer cancel()
639+ cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...)
640+ cmd.Env = waylandEnv()
641+ out, err := cmd.Output()
642+ return string(out), err == nil
643+}
644+
645+// payload is n bytes of printable text that differ along the way, so a
646+// misplaced chunk is caught.
647+func payload(n int) string {
648+ var b strings.Builder
649+ for i := 0; b.Len() < n; i++ {
650+ fmt.Fprintf(&b, "line %d é\n", i)
651+ }
652+ return b.String()[:n]
653+}
654+
655+// pasteLine is what x11probe -paste prints for want.
656+func pasteLine(want string) string {
657+ if len(want) <= 4096 {
658+ return fmt.Sprintf("paste %q", want)
659+ }
660+ return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want)))
661+}
662+
663+// TestXwaylandClipboard: the clipboard and the primary selection cross the
664+// X11/Wayland boundary in both directions, text only, small and INCR sized.
665+func TestXwaylandClipboard(t *testing.T) {
666+ needWlClipboard(t)
667+ e := startXwayland(t)
668+ startProbe(t, e.bin, e.display, "-title", "focus")
669+ until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil })
670+
671+ for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} {
672+ for _, size := range []int{4 << 10, 1 << 20} {
673+ name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10)
674+ text := payload(size)
675+ t.Run("W2X/"+name, func(t *testing.T) {
676+ var wargs []string
677+ if sel.w != "" {
678+ wargs = append(wargs, sel.w)
679+ }
680+ wlCopy(t, text, wargs...)
681+ until(t, "x11probe pastes the Wayland copy", func() bool {
682+ return runProbe(t, e, "-paste", sel.x) == pasteLine(text)
683+ })
684+ })
685+ t.Run("X2W/"+name, func(t *testing.T) {
686+ file := filepath.Join(t.TempDir(), "text")
687+ if err := os.WriteFile(file, []byte(text), 0o600); err != nil {
688+ t.Fatal(err)
689+ }
690+ p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s")
691+ until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") })
692+ var wargs []string
693+ if sel.w != "" {
694+ wargs = append(wargs, sel.w)
695+ }
696+ var got string
697+ until(t, "wl-paste returns the X copy", func() bool {
698+ var ok bool
699+ got, ok = wlPaste(wargs...)
700+ return ok && got == text
701+ })
702+ if got != text {
703+ t.Errorf("wl-paste differs: %d bytes", len(got))
704+ }
705+ })
706+ }
707+ }
708+
709+ t.Run("clear", func(t *testing.T) {
710+ wlCopy(t, "to be cleared")
711+ until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` })
712+ cmd := exec.Command("wl-copy", "--clear")
713+ cmd.Env = waylandEnv()
714+ if out, err := cmd.CombinedOutput(); err != nil {
715+ t.Fatalf("wl-copy --clear: %v: %s", err, out)
716+ }
717+ until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" })
718+ })
719+}
720+
721+// cursorTheme installs a one-arrow Xcursor theme of the given size and
722+// selects it through the XCURSOR_* environment.
723+func cursorTheme(t *testing.T, size int) {
724+ t.Helper()
725+ dir := t.TempDir()
726+ themeCursor(t, dir, "left_ptr", size, 1)
727+ t.Setenv("XCURSOR_PATH", dir)
728+ t.Setenv("XCURSOR_THEME", "theme")
729+ t.Setenv("XCURSOR_SIZE", strconv.Itoa(size))
730+}
731+
732+// TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme,
733+// the cursor size and the DPI of the first output.
734+func TestXwaylandXSettings(t *testing.T) {
735+ cursorTheme(t, 32)
736+ e := startXwayland(t)
737+ want := "xsettings serial=1 Xft/DPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32"
738+ var got string
739+ until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
740+ if got != want {
741+ t.Errorf("-xsettings = %q, want %q", got, want)
742+ }
743+}
744+
745+// TestXwaylandXSettingsScale2: the DPI follows the output scale.
746+func TestXwaylandXSettingsScale2(t *testing.T) {
747+ cursorTheme(t, 24)
748+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
749+ c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true})
750+ })
751+ var got string
752+ until(t, "Xft/DPI=196608", func() bool { got = runProbe(t, e, "-xsettings"); return strings.Contains(got, "Xft/DPI=196608") })
753+ if !strings.Contains(got, "Gtk/CursorThemeSize=24") {
754+ t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got)
755+ }
756+}
757+
758+// TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager.
759+func TestXwaylandXSettingsOff(t *testing.T) {
760+ e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false })
761+ // A mapped window proves the XWM is up before the absence is checked.
762+ p := startProbe(t, e.bin, e.display)
763+ until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
764+ if got := runProbe(t, e, "-xsettings"); got != "xsettings none" {
765+ t.Errorf("-xsettings = %q, want none", got)
766+ }
767+}
768+
769+// TestXwaylandRootCursor: the X background shows the themed arrow, at the
770+// theme size.
771+func TestXwaylandRootCursor(t *testing.T) {
772+ cursorTheme(t, 32)
773+ e := startXwayland(t)
774+ until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" })
775+ if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" {
776+ t.Errorf("-root-cursor = %q, want cursor 32x32", got)
777+ }
778+}
M internal/core/client.go +8 -0
@@ -70,6 +70,14 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish,
7070 if _, w := c.screenOf(v.ID); w != nil {
7171 w.SetModal(v.ID, v.Modal)
7272 }
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+ }
7381 case ports.WindowUnmapped:
7482 if c.unmapClient(ctx, v) != nil {
7583 return false, true
M internal/core/core.go +16 -5
@@ -103,9 +103,11 @@ type Core struct {
103103 binds map[binding]Action
104104 pressed map[string]bool
105105 configures configures
106- pointer WindowID
107- grab WindowID
108- buttons map[uint32]bool
106+ // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window.
107+ placed map[WindowID]Rect
108+ pointer WindowID
109+ grab WindowID
110+ buttons map[uint32]bool
109111 // drag is the pointer drag in progress (drag.go); swallow holds the
110112 // buttons whose release no client must see. mods are the modifiers
111113 // held, cmdMod the one keyboard.cmd names. lastButton is the latest
@@ -144,7 +146,11 @@ type Core struct {
144146 // popups are the placed xdg_popups; popupOrder stacks them, oldest first.
145147 popups map[WindowID]*popupState
146148 popupOrder []WindowID
147- keyboard keyboard
149+ // unmanaged are the mapped override-redirect windows, unmanagedOrder
150+ // stacks them, bottom to top (unmanaged.go).
151+ unmanaged map[WindowID]*unmanagedState
152+ unmanagedOrder []WindowID
153+ keyboard keyboard
148154 // activity is when core last told wayland about user input
149155 // (ports.UserActivity), sent at most once per ActivityInterval.
150156 activity time.Time
@@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) {
417423 return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1")
418424 }
419425 // 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()}
421427 c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
422428 if err := c.apply(cfg); err != nil {
423429 return nil, err
@@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
9941000 }
9951001 sc := c.screens[i]
9961002 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+ }
9971008 // Layer popups are over everything; window popups are over the windows
9981009 // only, under the top and overlay layers (as drawn).
9991010 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 {
3838 for {
3939 switch v := receive(t, ch); v.(type) {
4040 case nil, ports.SetOutputs, ports.UserActivity:
41+ case ports.PlaceWindow:
42+ // Only Positioned (X11) windows are placed; every scenario
43+ // using this helper maps xdg windows.
44+ t.Fatalf("unexpected %+v", v)
4145 default:
4246 return v
4347 }
M internal/core/datadrag.go +1 -1
@@ -101,7 +101,7 @@ func dataDragHitChanged(ev ports.ClientEvent) bool {
101101 case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped,
102102 ports.WindowResized, ports.PopupRequest, ports.PopupMapped,
103103 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,
105105 ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate:
106106 return true
107107 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/rules_test.go +44 -0
@@ -411,3 +411,47 @@ func TestRuleWidthWithFixedOverflow(t *testing.T) {
411411 t.Fatalf("%+v", s.Windows)
412412 }
413413 }
414+
415+func TestMapAtOutputPlacesOnThatOutput(t *testing.T) {
416+ r := startMulti(t, nil, left, right)
417+ r.mapApp(t, 1, "term")
418+ // DP-2 starts at layout x=200: a game mapping at its rect goes there.
419+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
420+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-2" {
421+ t.Fatalf("not on DP-2: %+v", w)
422+ }
423+}
424+
425+func TestMapAtOutputKeepsRuleMonitor(t *testing.T) {
426+ r := startMulti(t, func(c *ports.Config) {
427+ c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Monitor = "DP-1" })}
428+ }, left, right)
429+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
430+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" {
431+ t.Fatalf("rule monitor lost to the hint: %+v", w)
432+ }
433+}
434+
435+func TestMapAtOutputKeepsRuleWorkspace(t *testing.T) {
436+ r := startMulti(t, func(c *ports.Config) {
437+ c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Workspace = "1" })}
438+ }, left, right)
439+ r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0}
440+ if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" {
441+ t.Fatalf("rule workspace lost to the hint: %+v", w)
442+ }
443+}
444+
445+func TestRuleMatchesSteamAppClass(t *testing.T) {
446+ r := startMulti(t, func(c *ports.Config) {
447+ c.Rules = []ports.WindowRule{winRule("steam", `steam_app_\d+`, func(r *ports.WindowRule) { r.Floating = ruleOn() })}
448+ }, left)
449+ r.client <- ports.WindowMapped{ID: 1, AppID: "steam_app_892970", Positioned: true}
450+ if w := stWindow(r.stateWith(t, 1), 1); !w.Floating {
451+ t.Fatalf("steam_app rule did not match: %+v", w)
452+ }
453+ r.client <- ports.WindowMapped{ID: 2, AppID: "steam_app_x", Positioned: true}
454+ if w := stWindow(r.stateWith(t, 2), 2); w.Floating {
455+ t.Fatalf("non-numeric id matched: %+v", w)
456+ }
457+}
M internal/core/scene.go +12 -1
@@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
2020 // layout is what is drawn; settled (same indexes) is where the
2121 // windows are going, and alone sizes the configures.
2222 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)}
2424 if !sc.mon.previewing() {
2525 // Tiles stop at the panels; the overview lays out previews over
2626 // the whole output.
@@ -116,8 +116,19 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca
116116 }
117117 c.configures.mark(v)
118118 }
119+ if !p.Hidden && p.Preview == 0 && c.windows.lookup(p.ID).client.Positioned {
120+ cr := c.clientRect(ps)
121+ at := Rect{X: sc.x + cr.X, Y: sc.y + cr.Y}
122+ if old, ok := c.placed[p.ID]; !ok || old != at {
123+ if err := c.command(ctx, ports.PlaceWindow{ID: p.ID, X: at.X, Y: at.Y}); err != nil {
124+ return ports.Scene{}, err
125+ }
126+ c.placed[p.ID] = at
127+ }
128+ }
119129 }
120130 scene.Windows = append(scene.Windows, c.scenePopups(sc)...)
131+ scene.Windows = c.sceneUnmanaged(scene.Windows, sc)
121132 if icon, ok := c.dataDragIconOn(i, sc); ok {
122133 // The drag icon is over everything, popups and layers included.
123134 scene.Windows = append(scene.Windows, icon)
M internal/core/spawn_placement.go +9 -1
@@ -139,7 +139,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
139139 }
140140 }
141141 if s, _ := c.screenOf(v.ID); s == nil {
142- if eff, ok := c.ruleFor(v); ok {
142+ eff, ok := c.ruleFor(v)
143+ // A window mapping at an output's rect goes to that output unless a
144+ // rule names a monitor or workspace.
145+ if v.HasAt && !v.Remap && eff.monitor == "" && eff.workspace == "" && eff.number == 0 {
146+ if i := c.screenAt(float64(v.AtX), float64(v.AtY)); i >= 0 && i != c.focusScreen {
147+ eff.monitor, ok = c.screens[i].name(), true
148+ }
149+ }
150+ if ok {
143151 c.placeByRule(v, eff)
144152 return
145153 }
M internal/core/transition.go +1 -0
@@ -453,6 +453,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) {
453453 // The window is unmapped: a toplevel that maps again keeps its ID and
454454 // must be configured from nothing (a leaving entry keeps no configure).
455455 c.configures.forget(v.ID)
456+ delete(c.placed, v.ID)
456457 sc, w := c.screenOf(v.ID)
457458 if sc == nil {
458459 return
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 {
4747
4848 func (ConfigureWindow) clientCommand() {}
4949
50+// PlaceWindow carries core → wayland the position of a Positioned window's
51+// client rect, in layout logical coordinates (output origin included). It
52+// follows the ConfigureWindow of a frame and is sent once per change.
53+type PlaceWindow struct {
54+ ID WindowID
55+ X, Y int
56+}
57+
58+func (PlaceWindow) clientCommand() {}
59+
5060 // OutputPlacement is one output in the global layout. X, Y, Width and
5161 // Height are logical (after Transform: they swap for 90/270);
5262 // physical = logical × Scale. Info.Width and Info.Height are the physical
M internal/ports/client_events.go +33 -0
@@ -20,12 +20,20 @@ type WindowMapped struct {
2020 // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over
2121 // it.
2222 Parent WindowID
23+ // Positioned windows are placed by core: it sends PlaceWindow with the
24+ // client rect origin. Set only by the X11 host, false for xdg.
25+ Positioned bool
2326 // Remap is set when the toplevel mapped before and was unmapped (a tray
2427 // app restored): window rules only place its first map.
2528 Remap bool
2629 // Modal is set for a dialog that blocks its parent (xdg_dialog_v1):
2730 // focusing the parent focuses the dialog.
2831 Modal bool
32+ // HasAt asks for the screen containing the layout point (AtX, AtY) when
33+ // no rule sets a monitor: the X11 host sets it for a window that maps at
34+ // exactly one output's rect.
35+ HasAt bool
36+ AtX, AtY int
2937 }
3038
3139 // SlotEnv is the environment variable neferwl sets on processes it spawns
@@ -49,6 +57,31 @@ type WindowUnmapped struct{ ID WindowID }
4957
5058 func (WindowUnmapped) clientEvent() {}
5159
60+// UnmanagedMapped carries wayland → core that an override-redirect X11 window
61+// shows. The rect is its place in the layout (logical coordinates). NoInput
62+// windows (tooltips, drag icons, notifications) never take the pointer.
63+type UnmanagedMapped struct {
64+ ID WindowID
65+ X, Y, Width, Height int
66+ NoInput bool
67+}
68+
69+func (UnmanagedMapped) clientEvent() {}
70+
71+// UnmanagedMoved carries wayland → core a move and/or resize of a mapped
72+// unmanaged window.
73+type UnmanagedMoved struct {
74+ ID WindowID
75+ X, Y, Width, Height int
76+}
77+
78+func (UnmanagedMoved) clientEvent() {}
79+
80+// UnmanagedUnmapped carries wayland → core that an unmanaged window is gone.
81+type UnmanagedUnmapped struct{ ID WindowID }
82+
83+func (UnmanagedUnmapped) clientEvent() {}
84+
5285 // ShortcutsInhibit carries wayland → core that a window asks for (Active)
5386 // or stops asking for all keys, compositor binds included, while it has
5487 // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1).
M internal/ports/config.go +35 -3
@@ -101,9 +101,8 @@ type Config struct {
101101 Command []string
102102 AutoOpen string
103103 }
104- // Xwayland is the xwayland-satellite binary serving X11 clients; empty
105- // disables X11.
106- Xwayland string
104+ // Xwayland selects the X11 launcher.
105+ Xwayland XwaylandConfig
107106 // Startup are commands run once when the session starts, in order.
108107 // One startup line may hold several, split by SplitCommands.
109108 Startup [][]string
@@ -298,3 +297,36 @@ type WindowRule struct {
298297 // (1 to 16; 0 leaves it automatic).
299298 Cells int
300299 }
300+
301+// XwaylandBackend selects how X11 clients are served.
302+type XwaylandBackend uint8
303+
304+const (
305+ XwaylandOff XwaylandBackend = iota
306+ XwaylandSatellite
307+ XwaylandBuiltin
308+)
309+
310+// XwaylandConfig selects the launcher and its executable.
311+type XwaylandConfig struct {
312+ Backend XwaylandBackend
313+ Binary string
314+ // XSettings (xwayland.xsettings) turns the XSETTINGS manager of the
315+ // built-in XWM on.
316+ XSettings bool
317+}
318+
319+// XwaylandSettings is the plain data the built-in XWM publishes to X11
320+// clients; the app fills it from the config and the environment.
321+type XwaylandSettings struct {
322+ // XSettings turns the XSETTINGS manager on.
323+ XSettings bool
324+ // CursorTheme and CursorSize are XCURSOR_THEME and XCURSOR_SIZE
325+ // (published as Gtk/CursorThemeName and Gtk/CursorThemeSize).
326+ CursorTheme string
327+ CursorSize int
328+ // RootCursor sets a cursor on the X root window. Cursor is its image
329+ // (premultiplied ARGB8888, little-endian); empty uses the X "left_ptr" glyph.
330+ RootCursor bool
331+ Cursor CursorImage
332+}

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 24

Tags 10

↑↓ moveenter openesc close