Merge 41da69c5abbaae254105b5c5a4860b277dc81ca9 into fdd661a8e4494fc744eff9556e0eb653dcf7d996
102 files · +18370 -150
M .mockery.yml +30 -0
| @@ -87,6 +87,11 @@ packages: | |||||
| 87 | dir: internal/adapters/wayland | 87 | dir: internal/adapters/wayland | ||
| 88 | pkgname: wayland | 88 | pkgname: wayland | ||
| 89 | filename: acquire_poller_mock_test.go | 89 | 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 | |||
| 90 | syncobjDevice: | 95 | syncobjDevice: | ||
| 91 | config: | 96 | config: | ||
| 92 | dir: internal/adapters/wayland | 97 | dir: internal/adapters/wayland | ||
| @@ -187,3 +192,28 @@ packages: | |||||
| 187 | dir: internal/adapters/screencast | 192 | dir: internal/adapters/screencast | ||
| 188 | pkgname: screencast | 193 | pkgname: screencast | ||
| 189 | filename: pipewire_opener_mock_test.go | 194 | 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 | |||
| 215 | + | loop: | |||
| 216 | + | config: | |||
| 217 | + | dir: internal/adapters/wayland/xwm | |||
| 218 | + | pkgname: xwm | |||
| 219 | + | filename: loop_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 |
| 2 | 2 | ||||
| 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | ||
| 4 | TTY_TIMEOUT ?= 0 | 4 | TTY_TIMEOUT ?= 0 | ||
| @@ -26,6 +26,7 @@ test: | |||||
| 26 | CGO_ENABLED=0 go test ./... | 26 | CGO_ENABLED=0 go test ./... | ||
| 27 | vet: | 27 | vet: | ||
| 28 | CGO_ENABLED=0 go vet ./... | 28 | CGO_ENABLED=0 go vet ./... | ||
| 29 | + | CGO_ENABLED=0 go vet -tags xwayland ./internal/app | |||
| 29 | race: | 30 | race: | ||
| 30 | # cgo is enabled only for the race test binary. | 31 | # cgo is enabled only for the race test binary. | ||
| 31 | CGO_ENABLED=1 go test -race ./... | 32 | CGO_ENABLED=1 go test -race ./... | ||
| @@ -69,7 +70,8 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 70 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 71 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 72 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCommandForwardAllocations|TestOutboxQueuedDeliveryAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations|TestLogScenesAllocations|TestOverviewNamedRowsAllocations|TestBufferContentAllocations|TestDueFramesFireScratch|TestRetireSemaphoreAllocatesNothing)$$' -count=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 | |||
| 73 | check: vet test arch fakes-check log-check perf-check | 75 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 76 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 77 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
| @@ -77,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check | |||||
| 77 | color-check: | 79 | color-check: | ||
| 78 | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | 80 | CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v | ||
| 79 | 81 | ||||
| 82 | + | # End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a | |||
| 83 | + | # headless NeferWL. Needs Xwayland and a GPU; not part of check. | |||
| 84 | + | e2e-xwayland: | |||
| 85 | + | CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v | |||
| 86 | + | ||||
| 80 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 87 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | ||
| 81 | # come from the module proxy in prepare(); the build itself runs offline. | 88 | # come from the module proxy in prepare(); the build itself runs offline. | ||
| 82 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | 89 | # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0. | ||
M README.md +1 -1
| @@ -149,7 +149,7 @@ See [Configuration](docs/config.md) for all keys, actions, bindings and automati | |||||
| 149 | - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness. | 149 | - Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness. | ||
| 150 | - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects. | 150 | - Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects. | ||
| 151 | - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate. | 151 | - Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate. | ||
| 152 | - | - Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland. | 152 | + | - Steam and other X11 clients run through xwayland-satellite, or through the experimental built-in window manager (`xwayland = builtin`; 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. |
| 153 | 153 | ||||
| 154 | ### Windows and workspaces | 154 | ### Windows and workspaces | ||
| 155 | 155 | ||||
M docs/config.md +9 -3
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Configuration | 1 | # Configuration | ||
| 2 | 2 | ||||
| 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. |
| 4 | 4 | ||||
| 5 | ```sh | 5 | ```sh | ||
| 6 | neferwl --config <path> # use another file | 6 | neferwl --config <path> # use another file | ||
| @@ -27,7 +27,8 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 27 | | `terminal` | `$TERMINAL` or `foot` | Terminal command | | 27 | | `terminal` | `$TERMINAL` or `foot` | Terminal command | | ||
| 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | ||
| 29 | | `startup` | none | Commands run at session start, separated by `;` | | 29 | | `startup` | none | Commands run at session start, separated by `;` | | ||
| 30 | - | | `xwayland` | `xwayland-satellite` | X11 support; `off` disables it | | 30 | + | | `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path | |
| 31 | + | | `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor` and `Gdk/UnscaledDPI` to X11 apps through XSETTINGS (restart required); the scale is always announced as 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows; `off` leaves them to the apps | | |||
| 31 | | `background` | `#111111` | Background color | | 32 | | `background` | `#111111` | Background color | | ||
| 32 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | 33 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | ||
| 33 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | 34 | | `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 | |||||
| 103 | switches and mouse focus do not trigger a flash. | 104 | switches and mouse focus do not trigger a flash. | ||
| 104 | It runs when `animations = on`. | 105 | It runs when `animations = on`. | ||
| 105 | 106 | ||||
| 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`, a scale-1 DPI (96) through XSETTINGS (X11 apps see logical sizes; neferwl upscales their windows) (`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 | + | ||||
| 106 | ## Automatic terminal | 109 | ## Automatic terminal | ||
| 107 | 110 | ||||
| 108 | The command comes from `terminal`, then `$TERMINAL`, then `foot`. | 111 | The command comes from `terminal`, then `$TERMINAL`, then `foot`. | ||
| @@ -144,7 +147,7 @@ Rules place an app's windows when they first open. | |||||
| 144 | | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 | | 147 | | `rule.<name>.width` | Column width in [cells](#column-widths), 1 to 16 | | ||
| 145 | 148 | ||||
| 146 | ```ini | 149 | ```ini | ||
| 147 | - | rule.games.app-id = steam_app_.* | 150 | + | rule.games.app-id = steam_app_\d+ |
| 148 | rule.games.workspace = 3 | 151 | rule.games.workspace = 3 | ||
| 149 | rule.chat.app-id = discord|org\.telegram\..* | 152 | rule.chat.app-id = discord|org\.telegram\..* | ||
| 150 | rule.chat.workspace = chat | 153 | rule.chat.workspace = chat | ||
| @@ -155,6 +158,9 @@ rule.calc.floating = on | |||||
| 155 | 158 | ||||
| 156 | - Every matching rule applies, in file order; later rules win. | 159 | - Every matching rule applies, in file order; later rules win. | ||
| 157 | - A rule never moves your focus or view. | 160 | - 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. | |||
| 158 | - To find an app ID: `neferwl state | jq '.windows[].app_id'`. | 164 | - To find an app ID: `neferwl state | jq '.windows[].app_id'`. | ||
| 159 | 165 | ||||
| 160 | ## Output placement | 166 | ## Output placement | ||
M docs/desktop.md +12 -1
| @@ -52,12 +52,23 @@ Candidate popups are not drawn yet. | |||||
| 52 | 52 | ||||
| 53 | ## X11 apps | 53 | ## X11 apps | ||
| 54 | 54 | ||||
| 55 | - | X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself. | 55 | + | By default, X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7+, found in `PATH`. NeferWL starts it when the first X11 app connects; do not start it yourself. |
| 56 | 56 | ||||
| 57 | ```text | 57 | ```text | ||
| 58 | xwayland = off # no X11 support | 58 | xwayland = off # no X11 support | ||
| 59 | ``` | 59 | ``` | ||
| 60 | 60 | ||||
| 61 | + | With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI through XSETTINGS, always announced as scale 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor`, `Gdk/UnscaledDPI`; `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 window that maps with the fullscreen state already set (games do) opens fullscreen in a column of its own, focused, even if its size is fixed. A request made within 1 second *after* the map 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 | + | ||||
| 61 | ## Session locking | 72 | ## Session locking | ||
| 62 | 73 | ||||
| 63 | NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input. | 74 | 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 日本 | |||||
| 62 | 62 | ||||
| 63 | `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit. | 63 | `-count <n>` exits after n commits; `-delay` sets the time between preedit and commit. | ||
| 64 | 64 | ||||
| 65 | + | ## X11 check | |||
| 66 | + | ||||
| 67 | + | `make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. 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=… Gdk/WindowScalingFactor=… Gdk/UnscaledDPI=… 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` and `Gdk/WindowScalingFactor=1` at any output scale (also with `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 | + | ||||
| 65 | ## Colour check | 90 | ## Colour check | ||
| 66 | 91 | ||||
| 67 | `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`. | 92 | `make color-check` renders `examples/testpattern` on a headless output and checks the colours, in SDR and in HDR (`--headless-hdr`). It needs a GPU and `/dev/udmabuf`, and is not part of `make check`. | ||
M go.mod +4 -3
| @@ -3,10 +3,11 @@ module github.com/bnema/neferwl | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | + | bnema.dev/xturbo v0.1.0 | |||
| 6 | github.com/bnema/go-wayland-bindings v0.1.0 | 7 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | github.com/bnema/kvconf v0.1.2 | 8 | github.com/bnema/kvconf v0.1.2 | ||
| 8 | - | github.com/bnema/purego v0.13.0-bnema.1 | 9 | + | github.com/bnema/purego v0.13.0-bnema.2 |
| 9 | - | github.com/bnema/purego-libwayland v0.11.0 | 10 | + | github.com/bnema/purego-libwayland v0.12.0 |
| 10 | github.com/bnema/purego-pipewire v0.2.0 | 11 | github.com/bnema/purego-pipewire v0.2.0 | ||
| 11 | github.com/bnema/purego-vulkan v0.6.0 | 12 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 12 | github.com/bnema/wlturbo v0.5.0 | 13 | github.com/bnema/wlturbo v0.5.0 | ||
| @@ -16,7 +17,7 @@ require ( | |||||
| 16 | github.com/rs/zerolog v1.35.1 | 17 | github.com/rs/zerolog v1.35.1 | ||
| 17 | github.com/stretchr/testify v1.12.1 | 18 | github.com/stretchr/testify v1.12.1 | ||
| 18 | golang.org/x/image v0.46.0 | 19 | golang.org/x/image v0.46.0 | ||
| 19 | - | golang.org/x/sys v0.48.0 | 20 | + | golang.org/x/sys v0.49.0 |
| 20 | golang.org/x/term v0.46.0 | 21 | golang.org/x/term v0.46.0 | ||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
M go.sum +8 -6 generated file: collapsed
M internal/adapters/config/config.go +18 -4
| @@ -167,7 +167,7 @@ func Defaults() ports.Config { | |||||
| 167 | // Hint mode is experimental: off unless the config turns it on. | 167 | // Hint mode is experimental: off unless the config turns it on. | ||
| 168 | c.Hint.Enable = false | 168 | c.Hint.Enable = false | ||
| 169 | c.Hint.TextClick = ports.ModCtrl | ports.ModShift | 169 | 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} |
| 171 | c.Background.Color = "#111111" | 171 | c.Background.Color = "#111111" | ||
| 172 | c.Floating.Dim = 0.3 | 172 | c.Floating.Dim = 0.3 | ||
| 173 | c.Stash.Width = 80 | 173 | c.Stash.Width = 80 | ||
| @@ -724,13 +724,27 @@ func set(c *ports.Config, key, v string) error { | |||||
| 724 | } | 724 | } | ||
| 725 | c.Terminal.AutoOpen = v | 725 | c.Terminal.AutoOpen = v | ||
| 726 | case "xwayland": | 726 | case "xwayland": | ||
| 727 | + | xs := c.Xwayland.XSettings // independent of the backend | |||
| 727 | switch { | 728 | switch { | ||
| 728 | case v == "off": | 729 | 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} | |||
| 730 | case v == "" || strings.ContainsAny(v, " \t"): | 735 | 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") |
| 732 | default: | 737 | 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") | |||
| 734 | } | 748 | } | ||
| 735 | case "background": | 749 | case "background": | ||
| 736 | if !color.MatchString(v) { | 750 | if !color.MatchString(v) { | ||
M internal/adapters/config/config_test.go +36 -4
| @@ -340,13 +340,45 @@ func TestWorkspacePrevNextAction(t *testing.T) { | |||||
| 340 | } | 340 | } | ||
| 341 | 341 | ||||
| 342 | func TestXwayland(t *testing.T) { | 342 | func TestXwayland(t *testing.T) { | ||
| 343 | - | if Defaults().Xwayland != "xwayland-satellite" { | 343 | + | if Defaults().Xwayland != (ports.XwaylandConfig{Backend: ports.XwaylandSatellite, Binary: "xwayland-satellite", XSettings: true}) { |
| 344 | t.Fatal(Defaults().Xwayland) | 344 | t.Fatal(Defaults().Xwayland) | ||
| 345 | } | 345 | } | ||
| 346 | - | if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 { | 346 | + | for _, tt := range []struct { |
| 347 | - | t.Fatal(c.Xwayland, w) | 347 | + | value string |
| 348 | + | 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 | + | } | |||
| 348 | } | 379 | } | ||
| 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") { | |||
| 350 | t.Fatal(c.Xwayland, w) | 382 | t.Fatal(c.Xwayland, w) | ||
| 351 | } | 383 | } | ||
| 352 | } | 384 | } | ||
M internal/adapters/vulkan/capture_test.go +3 -0
| @@ -358,6 +358,9 @@ func openFDs(t *testing.T) int { | |||||
| 358 | // slot keeps its sync file for every lease. No mock stands in the measured | 358 | // slot keeps its sync file for every lease. No mock stands in the measured | ||
| 359 | // closure. | 359 | // closure. | ||
| 360 | func TestCaptureCycleAllocations(t *testing.T) { | 360 | func TestCaptureCycleAllocations(t *testing.T) { | ||
| 361 | + | if raceEnabled { | |||
| 362 | + | t.Skip("allocation budgets are not stable under the race runtime") | |||
| 363 | + | } | |||
| 361 | for _, tc := range []struct { | 364 | for _, tc := range []struct { | ||
| 362 | name string | 365 | name string | ||
| 363 | new func(*testing.T) *Renderer | 366 | new func(*testing.T) *Renderer | ||
A internal/adapters/vulkan/norace_test.go +5 -0
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !race | |||
| 2 | + | ||||
| 3 | + | package vulkan | |||
| 4 | + | ||||
| 5 | + | const raceEnabled = false | |||
A internal/adapters/vulkan/race_test.go +8 -0
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | //go:build race | |||
| 2 | + | ||||
| 3 | + | package vulkan | |||
| 4 | + | ||||
| 5 | + | // raceEnabled: sync.Pool drops items at random under the race runtime, so the | |||
| 6 | + | // allocation budgets of calls that go through purego cannot hold. perf-check | |||
| 7 | + | // runs them without it. | |||
| 8 | + | const raceEnabled = true | |||
M internal/adapters/vulkan/scene_perf_test.go +6 -0
| @@ -16,6 +16,9 @@ import ( | |||||
| 16 | // A new scene Seq with one of two windows moved is a partial redraw seeded by | 16 | // A new scene Seq with one of two windows moved is a partial redraw seeded by | ||
| 17 | // sceneDelta and allocates nothing either. | 17 | // sceneDelta and allocates nothing either. | ||
| 18 | func TestRenderSteadyStateAllocations(t *testing.T) { | 18 | func TestRenderSteadyStateAllocations(t *testing.T) { | ||
| 19 | + | if raceEnabled { | |||
| 20 | + | t.Skip("allocation budgets are not stable under the race runtime") | |||
| 21 | + | } | |||
| 19 | for _, tc := range []struct { | 22 | for _, tc := range []struct { | ||
| 20 | name string | 23 | name string | ||
| 21 | transform ports.BufferTransform | 24 | transform ports.BufferTransform | ||
| @@ -145,6 +148,9 @@ func TestRenderSteadyStateAllocations(t *testing.T) { | |||||
| 145 | // keeps one alloc of headroom for drivers whose import path differs. | 148 | // keeps one alloc of headroom for drivers whose import path differs. | ||
| 146 | // Skipped without a GPU or /dev/udmabuf. | 149 | // Skipped without a GPU or /dev/udmabuf. | ||
| 147 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | 150 | func TestRenderSteadyStateDMABufAllocations(t *testing.T) { | ||
| 151 | + | if raceEnabled { | |||
| 152 | + | t.Skip("allocation budgets are not stable under the race runtime") | |||
| 153 | + | } | |||
| 148 | r, err := New(80, 32) | 154 | r, err := New(80, 32) | ||
| 149 | if err != nil { | 155 | if err != nil { | ||
| 150 | t.Skipf("Vulkan unavailable: %v", err) | 156 | t.Skipf("Vulkan unavailable: %v", err) | ||
M internal/adapters/wayland/activation.go +3 -3
| @@ -66,7 +66,7 @@ func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf * | |||||
| 66 | } | 66 | } | ||
| 67 | var w *window | 67 | var w *window | ||
| 68 | if reason == "" { | 68 | if reason == "" { | ||
| 69 | - | if w = s.windows[state.root().windowID()]; w == nil || w.toplevel == nil || !w.mapped { | 69 | + | if w = s.windows[state.root().windowID()]; w == nil || !w.isToplevel() || !w.mapped { |
| 70 | reason = "not_toplevel" | 70 | reason = "not_toplevel" | ||
| 71 | } | 71 | } | ||
| 72 | } | 72 | } | ||
| @@ -116,8 +116,8 @@ func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |||||
| 116 | 116 | ||||
| 117 | // focusClient is the client with the keyboard focus. | 117 | // focusClient is the client with the keyboard focus. | ||
| 118 | func (s *Server) focusClient() server.Client { | 118 | func (s *Server) focusClient() server.Client { | ||
| 119 | - | if w := s.windows[s.seat.focused]; w != nil && w.xdg.resource.Resource.Alive() { | 119 | + | if w := s.windows[s.seat.focused]; w != nil && w.alive() { |
| 120 | - | return w.xdg.resource.Client() | 120 | + | return w.client() |
| 121 | } | 121 | } | ||
| 122 | if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() { | 122 | if l := s.layers[s.seat.focused]; l != nil && l.resource.Resource.Alive() { | ||
| 123 | return l.resource.Client() | 123 | return l.resource.Client() | ||
M internal/adapters/wayland/alloc_hotpath_test.go +9 -0
| @@ -246,3 +246,12 @@ func TestCommandForwardAllocations(t *testing.T) { | |||||
| 246 | t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs) | 246 | t.Errorf("command push + merge + drain + stats: %.1f allocs, want 0", allocs) | ||
| 247 | } | 247 | } | ||
| 248 | } | 248 | } | ||
| 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 { | |||||
| 37 | // used: the source was set, rejected or dragged; it cannot be set | 37 | // used: the source was set, rejected or dragged; it cannot be set | ||
| 38 | // again and takes no more MIME types. | 38 | // again and takes no more MIME types. | ||
| 39 | used bool | 39 | used bool | ||
| 40 | + | // x11: the offer is an X client's selection (xSource), not a Wayland one. | |||
| 41 | + | x11 bool | |||
| 40 | // res is the wl_data_source (nil for the other protocols); actions are | 42 | // res is the wl_data_source (nil for the other protocols); actions are | ||
| 41 | // its set_actions, for drag and drop only (dnd.go). | 43 | // its set_actions, for drag and drop only (dnd.go). | ||
| 42 | res *wayland.DataSource | 44 | res *wayland.DataSource | ||
| @@ -105,6 +107,7 @@ func (s *Server) setSelection(kind int, src *clipSource) { | |||||
| 105 | if src != nil && src != s.selections[kind] && src.alive() { | 107 | if src != nil && src != s.selections[kind] && src.alive() { | ||
| 106 | src.cancel() | 108 | src.cancel() | ||
| 107 | } | 109 | } | ||
| 110 | + | s.exposeToX(kind, nil) // X clients must not read a protected selection | |||
| 108 | return | 111 | return | ||
| 109 | } | 112 | } | ||
| 110 | old := s.selections[kind] | 113 | old := s.selections[kind] | ||
| @@ -116,6 +119,7 @@ func (s *Server) setSelection(kind int, src *clipSource) { | |||||
| 116 | old.cancel() | 119 | old.cancel() | ||
| 117 | } | 120 | } | ||
| 118 | s.announceAll(kind) | 121 | s.announceAll(kind) | ||
| 122 | + | s.exposeToX(kind, src) | |||
| 119 | } | 123 | } | ||
| 120 | 124 | ||||
| 121 | // announceAll sends the selection of kind to the focused client and to | 125 | // announceAll sends the selection of kind to the focused client and to | ||
| @@ -187,6 +191,7 @@ func (s *Server) sourceGone(src *clipSource) { | |||||
| 187 | if s.selections[kind] == src { | 191 | if s.selections[kind] == src { | ||
| 188 | s.selections[kind] = nil | 192 | s.selections[kind] = nil | ||
| 189 | s.announceAll(kind) | 193 | s.announceAll(kind) | ||
| 194 | + | s.exposeToX(kind, nil) | |||
| 190 | } | 195 | } | ||
| 191 | } | 196 | } | ||
| 192 | } | 197 | } | ||
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 | |||||
| 61 | return | 61 | return | ||
| 62 | } | 62 | } | ||
| 63 | w := s.windows[s.seat.pointerFocus] | 63 | w := s.windows[s.seat.pointerFocus] | ||
| 64 | - | if w == nil || !w.mapped || w.xdg.resource.Client() != r.Client() || s.surfaces[surf.Resource] != w.xdg.surface { | 64 | + | if w == nil || !w.mapped || w.client() != r.Client() || s.surfaces[surf.Resource] != w.surf() { |
| 65 | return | 65 | return | ||
| 66 | } | 66 | } | ||
| 67 | // Surface-local to window-local (core's, from the geometry origin). | 67 | // Surface-local to window-local (core's, from the geometry origin). | ||
| 68 | - | g := w.xdg.geometry | 68 | + | g := w.geometry() |
| 69 | s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)}) | 69 | s.emit(ports.PointerWarp{ID: w.id, X: x.Float() - float64(g.X), Y: y.Float() - float64(g.Y)}) | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| @@ -80,12 +80,12 @@ func (s *Server) relativeMotion(w *window, c ports.PointerMotionTo) bool { | |||||
| 80 | if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 { | 80 | if s.protected() || c.DX == 0 && c.DY == 0 && c.UnaccelDX == 0 && c.UnaccelDY == 0 { | ||
| 81 | return false | 81 | return false | ||
| 82 | } | 82 | } | ||
| 83 | - | if !w.mapped || !w.xdg.resource.Resource.Alive() { | 83 | + | if !w.mapped || !w.alive() { |
| 84 | return false | 84 | return false | ||
| 85 | } | 85 | } | ||
| 86 | sent := false | 86 | sent := false | ||
| 87 | usec := uint64(c.Time / time.Microsecond) | 87 | usec := uint64(c.Time / time.Microsecond) | ||
| 88 | - | for _, rel := range s.relatives[w.xdg.resource.Client()] { | 88 | + | for _, rel := range s.relatives[w.client()] { |
| 89 | if rel.Resource.Alive() { | 89 | if rel.Resource.Alive() { | ||
| 90 | rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY)) | 90 | rel.SendRelativeMotion(uint32(usec>>32), uint32(usec), server.FixedFromFloat(c.DX), server.FixedFromFloat(c.DY), server.FixedFromFloat(c.UnaccelDX), server.FixedFromFloat(c.UnaccelDY)) | ||
| 91 | sent = true | 91 | sent = true | ||
| @@ -272,7 +272,7 @@ func (s *Server) updateConstraint() { | |||||
| 272 | } | 272 | } | ||
| 273 | var want *constraint | 273 | var want *constraint | ||
| 274 | if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused { | 274 | if w := s.windows[s.seat.pointerFocus]; w != nil && w.mapped && s.seat.pointerFocus == s.seat.focused { | ||
| 275 | - | if c := s.constraints[w.xdg.surface]; c != nil && !c.defunct && (c.active || c.contains(w)) { | 275 | + | if c := s.constraints[w.surf()]; c != nil && !c.defunct && (c.active || c.contains(w)) { |
| 276 | want = c | 276 | want = c | ||
| 277 | } | 277 | } | ||
| 278 | } | 278 | } | ||
| @@ -298,8 +298,8 @@ func (s *Server) emitConstraint(c *constraint) { | |||||
| 298 | if s.protected() { | 298 | if s.protected() { | ||
| 299 | return | 299 | return | ||
| 300 | } | 300 | } | ||
| 301 | - | x := c.surface.xdg | 301 | + | w := c.surface.toplevelWindow() |
| 302 | - | if x == nil || x.window == nil { | 302 | + | if w == nil { |
| 303 | return | 303 | return | ||
| 304 | } | 304 | } | ||
| 305 | mode := ports.ConstraintConfine | 305 | mode := ports.ConstraintConfine | ||
| @@ -308,8 +308,9 @@ func (s *Server) emitConstraint(c *constraint) { | |||||
| 308 | } | 308 | } | ||
| 309 | r := c.region | 309 | r := c.region | ||
| 310 | if r.W > 0 && r.H > 0 { | 310 | if r.W > 0 && r.H > 0 { | ||
| 311 | - | r.X -= x.geometry.X | 311 | + | g := w.geometry() |
| 312 | - | r.Y -= x.geometry.Y | 312 | + | r.X -= g.X |
| 313 | + | r.Y -= g.Y | |||
| 313 | } | 314 | } | ||
| 314 | // Core supports one confinement rect, so this bounds the intersection; | 315 | // Core supports one confinement rect, so this bounds the intersection; | ||
| 315 | // contains uses the exact input region to decide activation. | 316 | // contains uses the exact input region to decide activation. | ||
| @@ -333,17 +334,17 @@ func (s *Server) emitConstraint(c *constraint) { | |||||
| 333 | r = box | 334 | r = box | ||
| 334 | } | 335 | } | ||
| 335 | } | 336 | } | ||
| 336 | - | s.emit(ports.PointerConstrained{ID: x.window.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}) | 337 | + | s.emit(ports.PointerConstrained{ID: w.id, PointerConstraint: ports.PointerConstraint{Mode: mode, Rect: r}}) |
| 337 | } | 338 | } | ||
| 338 | 339 | ||||
| 339 | // contains reports whether the pointer is in the region: a constraint | 340 | // contains reports whether the pointer is in the region: a constraint | ||
| 340 | // activates only there, so it never warps the pointer. | 341 | // activates only there, so it never warps the pointer. | ||
| 341 | func (c *constraint) contains(w *window) bool { | 342 | func (c *constraint) contains(w *window) bool { | ||
| 342 | r := c.region | 343 | r := c.region | ||
| 343 | - | x, y := w.surfacePoint(w.xdg.server.seat.pointerX, w.xdg.server.seat.pointerY) | 344 | + | x, y := w.surfacePoint(c.surface.server.seat.pointerX, c.surface.server.seat.pointerY) |
| 344 | all, rects := c.surface.effectiveInput() | 345 | all, rects := c.surface.effectiveInput() | ||
| 345 | if !all { | 346 | if !all { | ||
| 346 | - | localX, localY := x-float64(w.xdg.geometry.X), y-float64(w.xdg.geometry.Y) | 347 | + | localX, localY := x-float64(w.geometry().X), y-float64(w.geometry().Y) |
| 347 | inside := false | 348 | inside := false | ||
| 348 | for _, input := range rects { | 349 | for _, input := range rects { | ||
| 349 | if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) { | 350 | if localX >= float64(input.X) && localX < float64(input.X+input.W) && localY >= float64(input.Y) && localY < float64(input.Y+input.H) { | ||
| @@ -363,5 +364,5 @@ func (c *constraint) contains(w *window) bool { | |||||
| 363 | 364 | ||||
| 364 | // locked reports whether the window's pointer is locked. | 365 | // locked reports whether the window's pointer is locked. | ||
| 365 | func (s *Server) locked(w *window) bool { | 366 | func (s *Server) locked(w *window) bool { | ||
| 366 | - | return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.xdg.surface | 367 | + | return !s.protected() && s.constraint != nil && s.constraint.lock != nil && s.constraint.surface == w.surf() |
| 367 | } | 368 | } | ||
M internal/adapters/wayland/decoration.go +1 -1
| @@ -80,7 +80,7 @@ func (d *decoration) UnsetMode(r *xdgdecoration.ZxdgToplevelDecorationV1) | |||||
| 80 | func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) { | 80 | func (d *decoration) send(r *xdgdecoration.ZxdgToplevelDecorationV1) { | ||
| 81 | r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide)) | 81 | r.SendConfigure(uint32(xdgdecoration.ZxdgToplevelDecorationV1ModeServerSide)) | ||
| 82 | for _, w := range d.s.windows { | 82 | for _, w := range d.s.windows { | ||
| 83 | - | if w.toplevel == d.toplevel && w.hasLast && w.toplevel.Resource.Alive() && w.xdg.resource.Resource.Alive() { | 83 | + | if w.toplevel == d.toplevel && w.hasLast && w.toplevelAlive() && w.alive() { |
| 84 | w.sendConfigure() | 84 | w.sendConfigure() | ||
| 85 | return | 85 | return | ||
| 86 | } | 86 | } | ||
M internal/adapters/wayland/dialog.go +1 -1
| @@ -77,6 +77,6 @@ func (w *window) setModal(modal bool) { | |||||
| 77 | } | 77 | } | ||
| 78 | w.modal = modal | 78 | w.modal = modal | ||
| 79 | if w.mapped { | 79 | if w.mapped { | ||
| 80 | - | w.xdg.server.emit(ports.WindowModal{ID: w.id, Modal: modal}) | 80 | + | w.srv().emit(ports.WindowModal{ID: w.id, Modal: modal}) |
| 81 | } | 81 | } | ||
| 82 | } | 82 | } | ||
M internal/adapters/wayland/dmabuf.go +3 -2
| @@ -223,10 +223,11 @@ func (g *dmabufGlobal) fullscreenOutput(surf *surface) (string, bool) { | |||||
| 223 | return "", false | 223 | return "", false | ||
| 224 | } | 224 | } | ||
| 225 | root := toplevelRoot(surf) | 225 | root := toplevelRoot(surf) | ||
| 226 | - | if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast || !root.xdg.window.last.Fullscreen || g.server.invisible(surf) { | 226 | + | w := root.toplevelWindow() |
| 227 | + | if w == nil || !w.hasLast || !w.last.Fullscreen || g.server.invisible(surf) { | |||
| 227 | return "", false | 228 | return "", false | ||
| 228 | } | 229 | } | ||
| 229 | - | return root.xdg.window.last.Output, true | 230 | + | return w.last.Output, true |
| 230 | } | 231 | } | ||
| 231 | 232 | ||||
| 232 | // scanoutFor is the scanout offer for a surface: its window is fullscreen | 233 | // scanoutFor is the scanout offer for a surface: its window is fullscreen | ||
M internal/adapters/wayland/extforeigntoplevel.go +1 -1
| @@ -72,7 +72,7 @@ func (*extToplevelList) Destroy(*ext.ExtForeignToplevelListV1) {} | |||||
| 72 | func (s *Server) mappedToplevels() []ports.WindowID { | 72 | func (s *Server) mappedToplevels() []ports.WindowID { | ||
| 73 | ids := make([]ports.WindowID, 0, len(s.windows)) | 73 | ids := make([]ports.WindowID, 0, len(s.windows)) | ||
| 74 | for id, w := range s.windows { | 74 | for id, w := range s.windows { | ||
| 75 | - | if w.toplevel != nil && w.mapped { | 75 | + | if w.isToplevel() && w.mapped { |
| 76 | ids = append(ids, id) | 76 | ids = append(ids, id) | ||
| 77 | } | 77 | } | ||
| 78 | } | 78 | } | ||
M internal/adapters/wayland/foreigntoplevel.go +6 -6
| @@ -44,7 +44,7 @@ func registerForeignToplevel(d *server.Display, s *Server) error { | |||||
| 44 | r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) } | 44 | r.OnDestroy = func() { s.toplevelManagers = removeItem(s.toplevelManagers, m) } | ||
| 45 | ids := make([]ports.WindowID, 0, len(s.windows)) | 45 | ids := make([]ports.WindowID, 0, len(s.windows)) | ||
| 46 | for id, w := range s.windows { | 46 | for id, w := range s.windows { | ||
| 47 | - | if w.toplevel != nil && w.mapped { | 47 | + | if w.isToplevel() && w.mapped { |
| 48 | ids = append(ids, id) | 48 | ids = append(ids, id) | ||
| 49 | } | 49 | } | ||
| 50 | } | 50 | } | ||
| @@ -186,7 +186,7 @@ func toplevelState(w *window, version int32) []byte { | |||||
| 186 | 186 | ||||
| 187 | // toplevelChanged updates every manager after w mapped or changed. | 187 | // toplevelChanged updates every manager after w mapped or changed. | ||
| 188 | func (s *Server) toplevelChanged(w *window) { | 188 | func (s *Server) toplevelChanged(w *window) { | ||
| 189 | - | if w.toplevel == nil || !w.mapped { | 189 | + | if !w.isToplevel() || !w.mapped { |
| 190 | return | 190 | return | ||
| 191 | } | 191 | } | ||
| 192 | for _, m := range s.toplevelManagers { | 192 | for _, m := range s.toplevelManagers { | ||
| @@ -220,7 +220,7 @@ func (s *Server) refreshToplevels() { | |||||
| 220 | for _, l := range s.extToplevelLists { | 220 | for _, l := range s.extToplevelLists { | ||
| 221 | for id, h := range l.handles { | 221 | for id, h := range l.handles { | ||
| 222 | w := s.windows[id] | 222 | w := s.windows[id] | ||
| 223 | - | if h.closed || w == nil || w.toplevel == nil || !w.mapped { | 223 | + | if h.closed || w == nil || !w.isToplevel() || !w.mapped { |
| 224 | l.closed(id) | 224 | l.closed(id) | ||
| 225 | } | 225 | } | ||
| 226 | } | 226 | } | ||
| @@ -231,7 +231,7 @@ func (s *Server) refreshToplevels() { | |||||
| 231 | for _, m := range s.toplevelManagers { | 231 | for _, m := range s.toplevelManagers { | ||
| 232 | for id, h := range m.handles { | 232 | for id, h := range m.handles { | ||
| 233 | w := s.windows[id] | 233 | w := s.windows[id] | ||
| 234 | - | if h.req.closed || w == nil || w.toplevel == nil || !w.mapped { | 234 | + | if h.req.closed || w == nil || !w.isToplevel() || !w.mapped { |
| 235 | m.closed(id) | 235 | m.closed(id) | ||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| @@ -253,7 +253,7 @@ func (t *toplevelRequests) window() *window { | |||||
| 253 | if t.s.protected() || t.closed { | 253 | if t.s.protected() || t.closed { | ||
| 254 | return nil | 254 | return nil | ||
| 255 | } | 255 | } | ||
| 256 | - | if w := t.s.windows[t.id]; w != nil && w.toplevel != nil && w.mapped && w.toplevel.Resource.Alive() { | 256 | + | if w := t.s.windows[t.id]; w != nil && w.toplevelAlive() && w.mapped { |
| 257 | return w | 257 | return w | ||
| 258 | } | 258 | } | ||
| 259 | return nil | 259 | return nil | ||
| @@ -274,7 +274,7 @@ func (t *toplevelRequests) Activate(*wlr.ZwlrForeignToplevelHandleV1, *wayland.S | |||||
| 274 | } | 274 | } | ||
| 275 | func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) { | 275 | func (t *toplevelRequests) Close(*wlr.ZwlrForeignToplevelHandleV1) { | ||
| 276 | if w := t.window(); w != nil { | 276 | if w := t.window(); w != nil { | ||
| 277 | - | w.toplevel.SendClose() | 277 | + | w.sendClose() |
| 278 | } | 278 | } | ||
| 279 | } | 279 | } | ||
| 280 | func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) { | 280 | func (t *toplevelRequests) SetRectangle(r *wlr.ZwlrForeignToplevelHandleV1, _ *wayland.Surface, _, _, w, h int32) { | ||
M internal/adapters/wayland/frames.go +10 -6
| @@ -183,7 +183,7 @@ func (s *Server) pacePass(flipped map[string]bool, reports []ports.OutputPresent | |||||
| 183 | func toplevelRoot(surf *surface) *surface { | 183 | func toplevelRoot(surf *surface) *surface { | ||
| 184 | surf = surf.root() | 184 | surf = surf.root() | ||
| 185 | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | 185 | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | ||
| 186 | - | surf = surf.xdg.window.popup.parent.xdg.surface.root() | 186 | + | surf = surf.xdg.window.popup.parent.surf().root() |
| 187 | } | 187 | } | ||
| 188 | return surf | 188 | return surf | ||
| 189 | } | 189 | } | ||
| @@ -193,7 +193,8 @@ func toplevelRoot(surf *surface) *surface { | |||||
| 193 | // for a capture session is invisible. | 193 | // for a capture session is invisible. | ||
| 194 | func (s *Server) invisible(surf *surface) bool { | 194 | func (s *Server) invisible(surf *surface) bool { | ||
| 195 | root := toplevelRoot(surf) | 195 | root := toplevelRoot(surf) | ||
| 196 | - | return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible | 196 | + | w := root.toplevelWindow() |
| 197 | + | return w != nil && w.hasLast && !w.last.Visible | |||
| 197 | } | 198 | } | ||
| 198 | 199 | ||||
| 199 | // suspended reports that nothing draws the toplevel tree, not even a capture | 200 | // suspended reports that nothing draws the toplevel tree, not even a capture | ||
| @@ -201,10 +202,11 @@ func (s *Server) invisible(surf *surface) bool { | |||||
| 201 | // frame callbacks of its output so the capture keeps moving. | 202 | // frame callbacks of its output so the capture keeps moving. | ||
| 202 | func (s *Server) suspended(surf *surface) bool { | 203 | func (s *Server) suspended(surf *surface) bool { | ||
| 203 | root := toplevelRoot(surf) | 204 | root := toplevelRoot(surf) | ||
| 204 | - | if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast { | 205 | + | w := root.toplevelWindow() |
| 206 | + | if w == nil || !w.hasLast { | |||
| 205 | return false | 207 | return false | ||
| 206 | } | 208 | } | ||
| 207 | - | return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured | 209 | + | return !w.last.Visible && !w.last.Captured |
| 208 | } | 210 | } | ||
| 209 | 211 | ||||
| 210 | // frameOutput is the pacing class for a surface; "" is the 60 Hz | 212 | // frameOutput is the pacing class for a surface; "" is the 60 Hz | ||
| @@ -212,11 +214,13 @@ func (s *Server) suspended(surf *surface) bool { | |||||
| 212 | func (s *Server) frameOutput(surf *surface) string { | 214 | func (s *Server) frameOutput(surf *surface) string { | ||
| 213 | surf = toplevelRoot(surf) | 215 | surf = toplevelRoot(surf) | ||
| 214 | switch { | 216 | switch { | ||
| 215 | - | case surf.xdg != nil && surf.xdg.window != nil: | 217 | + | case surf.x11win != nil && surf.x11win.isOverride(): |
| 218 | + | return s.overrideOutput(surf.x11win) | |||
| 219 | + | case surf.toplevelWindow() != nil: | |||
| 216 | if s.suspended(surf) { | 220 | if s.suspended(surf) { | ||
| 217 | return suspendedFrameQueue | 221 | return suspendedFrameQueue | ||
| 218 | } | 222 | } | ||
| 219 | - | return surf.xdg.window.last.Output | 223 | + | return surf.toplevelWindow().last.Output |
| 220 | case surf.layer != nil && surf.layer.output != nil: | 224 | case surf.layer != nil && surf.layer.output != nil: | ||
| 221 | return surf.layer.output.name() | 225 | return surf.layer.output.name() | ||
| 222 | case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed: | 226 | case surf.lock != nil && surf.lock.output != nil && !surf.lock.closed: | ||
M internal/adapters/wayland/globals.go +11 -1
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | func registerGlobals(d *server.Display, o Options, s *Server) error { | 13 | func registerGlobals(d *server.Display, o Options, s *Server) error { | ||
| 14 | for _, register := range []func() error{ | 14 | for _, register := range []func() error{ | ||
| 15 | func() error { return registerXDG(d, s) }, | 15 | func() error { return registerXDG(d, s) }, | ||
| 16 | + | func() error { return registerXwaylandShell(d, s) }, | |||
| 17 | + | func() error { return registerXwaylandGrab(d, s) }, | |||
| 16 | func() error { return registerLayer(d, s) }, | 18 | func() error { return registerLayer(d, s) }, | ||
| 17 | func() error { | 19 | func() error { | ||
| 18 | // Advertise only with the production owner gate and authoritative | 20 | // Advertise only with the production owner gate and authoritative | ||
| @@ -131,8 +133,16 @@ func (c compositor) CreateSurface(r *wayland.Compositor, id uint32) { | |||||
| 131 | state.identity = c.server.nextSurface | 133 | state.identity = c.server.nextSurface | ||
| 132 | state.wl = w | 134 | state.wl = w | ||
| 133 | c.server.surfaces[w.Resource] = state | 135 | c.server.surfaces[w.Resource] = state | ||
| 136 | + | c.server.surfaceByIdentity[state.identity] = state | |||
| 134 | state.sendScale() | 137 | 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 | + | } | |||
| 136 | } | 146 | } | ||
| 137 | } | 147 | } | ||
| 138 | func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) { | 148 | func (c compositor) CreateRegion(r *wayland.Compositor, id uint32) { | ||
M internal/adapters/wayland/imagecopy.go +1 -1
| @@ -351,7 +351,7 @@ func (c *captureTarget) geometry() (o *output, rect image.Rectangle, hidden, ok | |||||
| 351 | case srcWindow: | 351 | case srcWindow: | ||
| 352 | // Core's state gives the size; before it, the last configure. | 352 | // Core's state gives the size; before it, the last configure. | ||
| 353 | w := c.s.windows[src.window] | 353 | w := c.s.windows[src.window] | ||
| 354 | - | if w == nil || !w.mapped || w.toplevel == nil || w.gen != src.gen { | 354 | + | if w == nil || !w.mapped || !w.isToplevel() || w.gen != src.gen { |
| 355 | return nil, image.Rectangle{}, false, false | 355 | return nil, image.Rectangle{}, false, false | ||
| 356 | } | 356 | } | ||
| 357 | name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height} | 357 | name, size := w.last.Output, ports.Rect{W: w.last.Width, H: w.last.Height} | ||
M internal/adapters/wayland/inhibit.go +5 -0
| @@ -121,6 +121,11 @@ func (s *Server) syncInhibitors() { | |||||
| 121 | idle[in.window] = true | 121 | idle[in.window] = true | ||
| 122 | } | 122 | } | ||
| 123 | } | 123 | } | ||
| 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 | + | } | |||
| 124 | for id, on := range diffSets(s.shortcutWindows, shortcuts) { | 129 | for id, on := range diffSets(s.shortcutWindows, shortcuts) { | ||
| 125 | s.emit(ports.ShortcutsInhibit{Window: id, Active: on}) | 130 | s.emit(ports.ShortcutsInhibit{Window: id, Active: on}) | ||
| 126 | } | 131 | } | ||
M internal/adapters/wayland/outputs.go +72 -1
| @@ -42,6 +42,8 @@ type output struct { | |||||
| 42 | global *server.Global | 42 | global *server.Global | ||
| 43 | resources []*wayland.Output | 43 | resources []*wayland.Output | ||
| 44 | xdgOutputs []*xdgoutput.ZxdgOutputV1 | 44 | xdgOutputs []*xdgoutput.ZxdgOutputV1 | ||
| 45 | + | // xdgOutputsX are the private xdg-outputs of the Xwayland client (X space). | |||
| 46 | + | xdgOutputsX []*xdgoutput.ZxdgOutputV1 | |||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 47 | func (o *output) name() string { return o.place.Info.Name } | 49 | func (o *output) name() string { return o.place.Info.Name } | ||
| @@ -89,6 +91,36 @@ func (o *output) sendXDG(x *xdgoutput.ZxdgOutputV1, withName bool) { | |||||
| 89 | } | 91 | } | ||
| 90 | } | 92 | } | ||
| 91 | 93 | ||||
| 94 | + | // sendXDGX sends the private xdg-output state: the layout position moved to | |||
| 95 | + | // X space and the logical size. | |||
| 96 | + | func (o *output) sendXDGX(s *Server, x *xdgoutput.ZxdgOutputV1, withName bool) { | |||
| 97 | + | ox, oy := s.xspace.Origin() | |||
| 98 | + | x.SendLogicalPosition(int32(o.place.X-ox), int32(o.place.Y-oy)) | |||
| 99 | + | x.SendLogicalSize(int32(o.place.Width), int32(o.place.Height)) | |||
| 100 | + | if withName && x.Version() >= 2 { | |||
| 101 | + | x.SendName(o.name()) | |||
| 102 | + | x.SendDescription(o.description()) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // republishXDGX resends every private xdg-output after a layout change, so | |||
| 107 | + | // Xwayland resizes the root and its RandR outputs. | |||
| 108 | + | func (s *Server) republishXDGX() { | |||
| 109 | + | for _, o := range s.outputs { | |||
| 110 | + | for _, x := range o.xdgOutputsX { | |||
| 111 | + | o.sendXDGX(s, x, false) | |||
| 112 | + | if x.Version() < 3 { | |||
| 113 | + | x.SendDone() | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | for _, r := range o.resources { | |||
| 117 | + | if r.Client() == s.xwayland.client && r.Version() >= 2 { | |||
| 118 | + | r.SendDone() | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 92 | // outputOf returns the output a wl_output resource belongs to, or nil for a | 124 | // outputOf returns the output a wl_output resource belongs to, or nil for a | ||
| 93 | // removed one. | 125 | // removed one. | ||
| 94 | func (s *Server) outputOf(r *wayland.Output) *output { | 126 | func (s *Server) outputOf(r *wayland.Output) *output { | ||
| @@ -162,6 +194,10 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 162 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | 194 | changed := !slices.Equal(s.outputPlaces, c.Outputs) | ||
| 163 | focusChanged := s.focusedOutput != c.Focused | 195 | focusChanged := s.focusedOutput != c.Focused | ||
| 164 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | 196 | s.outputPlaces = append(ports.Layout(nil), c.Outputs...) | ||
| 197 | + | xChanged := s.xspace.Set(s.outputPlaces) | |||
| 198 | + | if s.xwm != nil { | |||
| 199 | + | s.xwm.LayoutChanged(xChanged) | |||
| 200 | + | } | |||
| 165 | defer func() { | 201 | defer func() { | ||
| 166 | if changed { | 202 | if changed { | ||
| 167 | s.refreshOutputManagers() | 203 | s.refreshOutputManagers() | ||
| @@ -244,6 +280,9 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||||
| 244 | for _, surf := range s.surfaces { | 280 | for _, surf := range s.surfaces { | ||
| 245 | surf.sendScale() | 281 | surf.sendScale() | ||
| 246 | } | 282 | } | ||
| 283 | + | if changed { | |||
| 284 | + | s.republishXDGX() | |||
| 285 | + | } | |||
| 247 | s.notifyColorFeedback() | 286 | s.notifyColorFeedback() | ||
| 248 | // Layer surfaces sized from the output follow its new logical size. | 287 | // Layer surfaces sized from the output follow its new logical size. | ||
| 249 | adopted := false | 288 | adopted := false | ||
| @@ -279,10 +318,42 @@ type outputHandler struct{} | |||||
| 279 | 318 | ||||
| 280 | func (outputHandler) Release(*wayland.Output) {} | 319 | func (outputHandler) Release(*wayland.Output) {} | ||
| 281 | 320 | ||||
| 321 | + | // registerXDGOutput registers the public zxdg_output_manager_v1 and a second, | |||
| 322 | + | // private one for Xwayland whose outputs are in X space (see globalVisible). | |||
| 282 | func registerXDGOutput(d *server.Display, s *Server) error { | 323 | func registerXDGOutput(d *server.Display, s *Server) error { | ||
| 283 | - | return xdgoutput.NewZxdgOutputManagerV1Global(d, 3, func(c server.Client, v, id uint32) { | 324 | + | var err error |
| 325 | + | s.xdgPublic, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) { | |||
| 284 | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s}) | 326 | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManager{s}) | ||
| 285 | }) | 327 | }) | ||
| 328 | + | if err != nil { | |||
| 329 | + | return err | |||
| 330 | + | } | |||
| 331 | + | s.xdgPrivate, err = d.AddGlobal(xdgoutput.ZxdgOutputManagerV1Interface, 3, func(c server.Client, v, id uint32) { | |||
| 332 | + | _, _ = xdgoutput.NewZxdgOutputManagerV1(c, int32(v), id, xdgOutputManagerX{s}) | |||
| 333 | + | }) | |||
| 334 | + | return err | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | type xdgOutputManagerX struct{ server *Server } | |||
| 338 | + | ||||
| 339 | + | func (xdgOutputManagerX) Destroy(*xdgoutput.ZxdgOutputManagerV1) {} | |||
| 340 | + | func (m xdgOutputManagerX) GetXdgOutput(r *xdgoutput.ZxdgOutputManagerV1, id uint32, wl *wayland.Output) { | |||
| 341 | + | x, err := xdgoutput.NewZxdgOutputV1(r.Client(), r.Version(), id, xdgOutput{}) | |||
| 342 | + | if err != nil { | |||
| 343 | + | return | |||
| 344 | + | } | |||
| 345 | + | o := m.server.outputOf(wl) | |||
| 346 | + | if o == nil { | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | o.xdgOutputsX = append(o.xdgOutputsX, x) | |||
| 350 | + | x.OnDestroy = func() { o.xdgOutputsX = removeItem(o.xdgOutputsX, x) } | |||
| 351 | + | o.sendXDGX(m.server, x, true) | |||
| 352 | + | if r.Version() < 3 || wl.Version() < 2 { | |||
| 353 | + | x.SendDone() | |||
| 354 | + | } else { | |||
| 355 | + | wl.SendDone() | |||
| 356 | + | } | |||
| 286 | } | 357 | } | ||
| 287 | 358 | ||||
| 288 | type xdgOutputManager struct{ server *Server } | 359 | type xdgOutputManager struct{ server *Server } | ||
M internal/adapters/wayland/popup.go +2 -2
| @@ -31,7 +31,7 @@ func (s *Server) newPopup(x *xdgSurface, r *xdgshell.Surface, id uint32, parentX | |||||
| 31 | var parent *window | 31 | var parent *window | ||
| 32 | if parentXDG != nil { | 32 | if parentXDG != nil { | ||
| 33 | for _, w := range s.windows { | 33 | for _, w := range s.windows { | ||
| 34 | - | if w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource { | 34 | + | if w.xdg != nil && w.xdg.resource != nil && w.xdg.resource.Resource == parentXDG.Resource { |
| 35 | parent = w | 35 | parent = w | ||
| 36 | } | 36 | } | ||
| 37 | } | 37 | } | ||
| @@ -166,7 +166,7 @@ func (p *popup) configure(c ports.ConfigurePopup) { | |||||
| 166 | func (s *Server) topGrab(client server.Client, parent *window) bool { | 166 | func (s *Server) topGrab(client server.Client, parent *window) bool { | ||
| 167 | var top *window | 167 | var top *window | ||
| 168 | for _, w := range s.windows { | 168 | for _, w := range s.windows { | ||
| 169 | - | if w.popup != nil && w.popup.grab && !w.popup.done && w.xdg.resource.Client() == client && (top == nil || w.id > top.id) { | 169 | + | if w.popup != nil && w.popup.grab && !w.popup.done && w.client() == client && (top == nil || w.id > top.id) { |
| 170 | top = w | 170 | top = w | ||
| 171 | } | 171 | } | ||
| 172 | } | 172 | } | ||
M internal/adapters/wayland/scale.go +5 -3
| @@ -21,12 +21,14 @@ func (s *Server) outputOfSurface(surf *surface) *output { | |||||
| 21 | surf = surf.root() | 21 | surf = surf.root() | ||
| 22 | // Popups are on their parent's output. | 22 | // Popups are on their parent's output. | ||
| 23 | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | 23 | for surf.xdg != nil && surf.xdg.window != nil && surf.xdg.window.popup != nil && surf.xdg.window.popup.parent != nil { | ||
| 24 | - | surf = surf.xdg.window.popup.parent.xdg.surface.root() | 24 | + | surf = surf.xdg.window.popup.parent.surf().root() |
| 25 | } | 25 | } | ||
| 26 | name := "" | 26 | name := "" | ||
| 27 | switch { | 27 | switch { | ||
| 28 | - | case surf.xdg != nil && surf.xdg.window != nil: | 28 | + | case surf.x11win != nil && surf.x11win.isOverride(): |
| 29 | - | name = surf.xdg.window.last.Output | 29 | + | name = s.overrideOutput(surf.x11win) |
| 30 | + | case surf.toplevelWindow() != nil: | |||
| 31 | + | name = surf.toplevelWindow().last.Output | |||
| 30 | case surf.layer != nil && surf.layer.output != nil: | 32 | case surf.layer != nil && surf.layer.output != nil: | ||
| 31 | return surf.layer.output | 33 | return surf.layer.output | ||
| 32 | case surf.lock != nil: | 34 | case surf.lock != nil: | ||
M internal/adapters/wayland/screencast.go +1 -1
| @@ -146,7 +146,7 @@ func (s *Server) openCast(cmd ports.CastCommand) { | |||||
| 146 | gone := ports.CaptureReasonOutputGone | 146 | gone := ports.CaptureReasonOutputGone | ||
| 147 | if cmd.Window != 0 { | 147 | if cmd.Window != 0 { | ||
| 148 | gone = ports.CaptureReasonWindowGone | 148 | gone = ports.CaptureReasonWindowGone | ||
| 149 | - | if w := s.windows[cmd.Window]; w != nil && w.mapped && w.toplevel != nil { | 149 | + | if w := s.windows[cmd.Window]; w != nil && w.mapped && w.isToplevel() { |
| 150 | src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen} | 150 | src = &captureSource{kind: srcWindow, window: cmd.Window, gen: w.gen} | ||
| 151 | open.Window = cmd.Window | 151 | open.Window = cmd.Window | ||
| 152 | } | 152 | } | ||
M internal/adapters/wayland/seat.go +11 -9
| @@ -276,10 +276,10 @@ func (s *Server) setModifiers(c ports.SetModifiers) { | |||||
| 276 | } | 276 | } | ||
| 277 | 277 | ||||
| 278 | func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard { | 278 | func (s *Server) windowKeyboards(w *window) []*wayland.Keyboard { | ||
| 279 | - | if w == nil || !w.mapped || !w.xdg.resource.Resource.Alive() { | 279 | + | if w == nil || !w.mapped || !w.alive() { |
| 280 | return nil | 280 | return nil | ||
| 281 | } | 281 | } | ||
| 282 | - | return s.clientKeyboards(w.xdg.resource.Client()) | 282 | + | return s.clientKeyboards(w.client()) |
| 283 | } | 283 | } | ||
| 284 | 284 | ||||
| 285 | // clientKeyboards returns c's wl_keyboards without allocating. The list holds | 285 | // clientKeyboards returns c's wl_keyboards without allocating. The list holds | ||
| @@ -300,8 +300,8 @@ func (s *Server) focusTarget(id ports.WindowID) (*wayland.Surface, []*wayland.Ke | |||||
| 300 | // already dead. Admission is enforced before entering or delivering. | 300 | // already dead. Admission is enforced before entering or delivering. | ||
| 301 | return l.surface.wl, s.clientKeyboards(l.surface.wl.Client()) | 301 | return l.surface.wl, s.clientKeyboards(l.surface.wl.Client()) | ||
| 302 | } | 302 | } | ||
| 303 | - | if w := s.windows[id]; w != nil && w.mapped { | 303 | + | if w := s.windows[id]; w != nil && w.mapped && !w.isOverride() { |
| 304 | - | if surf := w.xdg.surfaceResource(); surf != nil { | 304 | + | if surf := w.surfaceResource(); surf != nil { |
| 305 | return surf, s.windowKeyboards(w) | 305 | return surf, s.windowKeyboards(w) | ||
| 306 | } | 306 | } | ||
| 307 | } | 307 | } | ||
| @@ -319,8 +319,8 @@ func (s *Server) pointerTarget(id ports.WindowID) (server.Client, []*wayland.Poi | |||||
| 319 | if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) { | 319 | if l := s.lockSurfaces[id]; l != nil && s.lockInputTarget(id) { | ||
| 320 | return l.resource.Client(), s.clientPointers(l.resource.Client()), true | 320 | return l.resource.Client(), s.clientPointers(l.resource.Client()), true | ||
| 321 | } | 321 | } | ||
| 322 | - | if w := s.windows[id]; w != nil && w.mapped && w.xdg.resource.Resource.Alive() { | 322 | + | if w := s.windows[id]; w != nil && w.mapped && w.alive() { |
| 323 | - | return w.xdg.resource.Client(), s.windowPointers(w), true | 323 | + | return w.client(), s.windowPointers(w), true |
| 324 | } | 324 | } | ||
| 325 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | 325 | if l := s.layers[id]; l != nil && l.mapped && l.resource.Resource.Alive() { | ||
| 326 | return l.resource.Client(), s.layerPointers(l), true | 326 | return l.resource.Client(), s.layerPointers(l), true | ||
| @@ -335,7 +335,7 @@ func (s *Server) pointerSurface(id ports.WindowID, x, y float64) (*wayland.Surfa | |||||
| 335 | return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y | 335 | return l.surface.wl, s.clientPointers(l.surface.wl.Client()), x, y | ||
| 336 | } | 336 | } | ||
| 337 | if w := s.windows[id]; w != nil { | 337 | if w := s.windows[id]; w != nil { | ||
| 338 | - | if surf := w.xdg.surfaceResource(); surf != nil && surf.Resource.Alive() { | 338 | + | if surf := w.surfaceResource(); surf != nil && surf.Resource.Alive() { |
| 339 | x, y := w.surfacePoint(x, y) | 339 | x, y := w.surfacePoint(x, y) | ||
| 340 | return surf, s.windowPointers(w), x, y | 340 | return surf, s.windowPointers(w), x, y | ||
| 341 | } | 341 | } | ||
| @@ -455,6 +455,8 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||||
| 455 | } | 455 | } | ||
| 456 | } | 456 | } | ||
| 457 | s.textInputFocus() | 457 | s.textInputFocus() | ||
| 458 | + | s.x11Focus() | |||
| 459 | + | s.syncKeyboardGrab() | |||
| 458 | s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus") | 460 | s.log.Debug().Uint64("id", uint64(s.seat.focused)).Msg("keyboard focus") | ||
| 459 | s.updateConstraint() | 461 | s.updateConstraint() | ||
| 460 | } | 462 | } | ||
| @@ -543,10 +545,10 @@ func pointerFrame(p *wayland.Pointer) { | |||||
| 543 | } | 545 | } | ||
| 544 | } | 546 | } | ||
| 545 | func (s *Server) windowPointers(w *window) []*wayland.Pointer { | 547 | 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() { |
| 547 | return nil | 549 | return nil | ||
| 548 | } | 550 | } | ||
| 549 | - | return s.clientPointers(w.xdg.resource.Client()) | 551 | + | return s.clientPointers(w.client()) |
| 550 | } | 552 | } | ||
| 551 | 553 | ||||
| 552 | func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer { | 554 | func (s *Server) layerPointers(l *layerSurface) []*wayland.Pointer { | ||
M internal/adapters/wayland/server.go +51 -16
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/neferwl/internal/adapters/captureallow" | 16 | "github.com/bnema/neferwl/internal/adapters/captureallow" | ||
| 17 | "github.com/bnema/neferwl/internal/adapters/clock" | 17 | "github.com/bnema/neferwl/internal/adapters/clock" | ||
| 18 | "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock" | 18 | "github.com/bnema/neferwl/internal/adapters/wayland/sessionlock" | ||
| 19 | + | "github.com/bnema/neferwl/internal/adapters/wayland/xwm" | |||
| 19 | "github.com/bnema/neferwl/internal/adapters/workspaceid" | 20 | "github.com/bnema/neferwl/internal/adapters/workspaceid" | ||
| 20 | "github.com/bnema/neferwl/internal/ports" | 21 | "github.com/bnema/neferwl/internal/ports" | ||
| 21 | "github.com/bnema/purego-libwayland/server" | 22 | "github.com/bnema/purego-libwayland/server" | ||
| @@ -51,6 +52,9 @@ type Options struct { | |||||
| 51 | // Casts receives the screencast events and inventory (screencast.go); | 52 | // Casts receives the screencast events and inventory (screencast.go); | ||
| 52 | // nil serves no casts (Channels.CastCommands is not read). | 53 | // nil serves no casts (Channels.CastCommands is not read). | ||
| 53 | Casts ports.CastSink | 54 | 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 | |||
| 54 | // LogDamage logs the damage of every fresh client commit at debug level | 58 | // LogDamage logs the damage of every fresh client commit at debug level | ||
| 55 | // (--debug=wayland-damage). | 59 | // (--debug=wayland-damage). | ||
| 56 | LogDamage bool | 60 | LogDamage bool | ||
| @@ -160,13 +164,21 @@ type Server struct { | |||||
| 160 | securityStop int | 164 | securityStop int | ||
| 161 | securityWG sync.WaitGroup | 165 | securityWG sync.WaitGroup | ||
| 162 | securityLive atomic.Int32 | 166 | securityLive atomic.Int32 | ||
| 163 | - | display *server.Display | 167 | + | xwayland xwaylandClient // display owner |
| 164 | - | env procEnv | 168 | + | // xdgPublic and xdgPrivate are the two zxdg_output_manager_v1 globals: |
| 165 | - | name string | 169 | + | // layout space for clients, X space for Xwayland. |
| 166 | - | cleanup func() | 170 | + | xdgPublic, xdgPrivate *server.Global |
| 167 | - | log zerowrap.Logger | 171 | + | // xwm manages the X11 windows of the Xwayland client; x11Slots maps its |
| 168 | - | logDamage bool | 172 | + | // slots to their windows. Display goroutine only. |
| 169 | - | channels Channels | 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 | |||
| 170 | // awaiting holds frame callbacks by output name, due at its next | 182 | // awaiting holds frame callbacks by output name, due at its next | ||
| 171 | // frame (frameDue, or its page flip). | 183 | // frame (frameDue, or its page flip). | ||
| 172 | awaiting map[string][]*wayland.Callback | 184 | awaiting map[string][]*wayland.Callback | ||
| @@ -214,8 +226,11 @@ type Server struct { | |||||
| 214 | // foreignExports are the live xdg-foreign handles (foreign.go). | 226 | // foreignExports are the live xdg-foreign handles (foreign.go). | ||
| 215 | foreignExports map[string]*foreignExport | 227 | foreignExports map[string]*foreignExport | ||
| 216 | surfaces map[*server.Resource]*surface | 228 | surfaces map[*server.Resource]*surface | ||
| 217 | - | buffers map[*server.Resource]clientBuffer | 229 | + | // surfaceByIdentity finds a surface by its identity (xwayland_shell_v1 |
| 218 | - | dmabuf *dmabufGlobal | 230 | + | // pairing); kept with surfaces. |
| 231 | + | surfaceByIdentity map[uint64]*surface | |||
| 232 | + | buffers map[*server.Resource]clientBuffer | |||
| 233 | + | dmabuf *dmabufGlobal | |||
| 219 | // hdrOutputs records confirmed DRM modesets; absent outputs are SDR. | 234 | // hdrOutputs records confirmed DRM modesets; absent outputs are SDR. | ||
| 220 | hdrOutputs map[string]ports.OutputHDR | 235 | hdrOutputs map[string]ports.OutputHDR | ||
| 221 | colorID uint64 | 236 | colorID uint64 | ||
| @@ -262,6 +277,8 @@ type Server struct { | |||||
| 262 | // inhibitors are the live inhibitor objects (inhibit.go); | 277 | // inhibitors are the live inhibitor objects (inhibit.go); | ||
| 263 | // shortcutWindows and idleWindows what core was told. | 278 | // shortcutWindows and idleWindows what core was told. | ||
| 264 | inhibitors []*inhibitor | 279 | inhibitors []*inhibitor | ||
| 280 | + | xgrab *xwaylandGrab // Xwayland keyboard grab (xwaylandgrab.go) | |||
| 281 | + | xgrabbed ports.WindowID // window the grab inhibits for | |||
| 265 | shortcutWindows, idleWindows map[ports.WindowID]bool | 282 | shortcutWindows, idleWindows map[ports.WindowID]bool | ||
| 266 | // busInhibited is the last IdleInhibited value (idle.go). | 283 | // busInhibited is the last IdleInhibited value (idle.go). | ||
| 267 | busInhibited bool | 284 | busInhibited bool | ||
| @@ -281,6 +298,8 @@ type Server struct { | |||||
| 281 | workspaceIDs *workspaceid.IDs | 298 | workspaceIDs *workspaceid.IDs | ||
| 282 | outputHeads ports.OutputHeads | 299 | outputHeads ports.OutputHeads | ||
| 283 | outputPlaces ports.Layout | 300 | outputPlaces ports.Layout | ||
| 301 | + | xsettings ports.XwaylandSettings | |||
| 302 | + | xspace xwm.Space // the one layout <-> X mapping, shared with the XWM | |||
| 284 | managementSerial uint32 | 303 | managementSerial uint32 | ||
| 285 | nextOutputApply uint64 | 304 | nextOutputApply uint64 | ||
| 286 | outputReplies map[uint64]*outputConfiguration | 305 | outputReplies map[uint64]*outputConfiguration | ||
| @@ -298,6 +317,8 @@ type Server struct { | |||||
| 298 | cursorReady chan struct{} | 317 | cursorReady chan struct{} | ||
| 299 | // Clipboard and primary selection (clipboard.go). | 318 | // Clipboard and primary selection (clipboard.go). | ||
| 300 | selections [2]*clipSource | 319 | 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 | |||
| 301 | // Drag and drop (dnd.go): the drag in progress, drops waiting for | 322 | // Drag and drop (dnd.go): the drag in progress, drops waiting for | ||
| 302 | // their finish, and each client's data devices. | 323 | // their finish, and each client's data devices. | ||
| 303 | dnd *dndState | 324 | dnd *dndState | ||
| @@ -364,10 +385,13 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 364 | cleanup: cleanup, | 385 | cleanup: cleanup, | ||
| 365 | log: log, | 386 | log: log, | ||
| 366 | logDamage: opts.LogDamage, | 387 | logDamage: opts.LogDamage, | ||
| 388 | + | xsettings: opts.Xwayland, | |||
| 367 | channels: ch, | 389 | channels: ch, | ||
| 368 | surfaces: make(map[*server.Resource]*surface), | 390 | surfaces: make(map[*server.Resource]*surface), | ||
| 391 | + | surfaceByIdentity: make(map[uint64]*surface), | |||
| 369 | buffers: make(map[*server.Resource]clientBuffer), | 392 | buffers: make(map[*server.Resource]clientBuffer), | ||
| 370 | windows: make(map[ports.WindowID]*window), | 393 | windows: make(map[ports.WindowID]*window), | ||
| 394 | + | x11Slots: make(map[xwm.Slot]*window), | |||
| 371 | layers: make(map[ports.WindowID]*layerSurface), | 395 | layers: make(map[ports.WindowID]*layerSurface), | ||
| 372 | nextWindow: 1, | 396 | nextWindow: 1, | ||
| 373 | eventReady: make(chan struct{}, 1), | 397 | eventReady: make(chan struct{}, 1), | ||
| @@ -498,6 +522,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 498 | if err = s.openPaceTimer(); err != nil { | 522 | if err = s.openPaceTimer(); err != nil { | ||
| 499 | return abort(err) | 523 | return abort(err) | ||
| 500 | } | 524 | } | ||
| 525 | + | d.SetGlobalFilter(s.globalVisible) | |||
| 501 | if err = registerGlobals(d, opts, s); err != nil { | 526 | if err = registerGlobals(d, opts, s); err != nil { | ||
| 502 | return abort(err) | 527 | return abort(err) | ||
| 503 | } | 528 | } | ||
| @@ -585,6 +610,13 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 585 | } | 610 | } | ||
| 586 | s.castInventoryChanged() | 611 | s.castInventoryChanged() | ||
| 587 | err := s.display.Run(ctx) | 612 | 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() | |||
| 588 | s.stopSecurityContexts() | 620 | s.stopSecurityContexts() | ||
| 589 | wg.Wait() | 621 | wg.Wait() | ||
| 590 | return err | 622 | return err | ||
| @@ -774,9 +806,11 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 774 | if w := s.windows[c.ID]; w != nil && w.popup != nil { | 806 | if w := s.windows[c.ID]; w != nil && w.popup != nil { | ||
| 775 | w.popup.dismiss() | 807 | w.popup.dismiss() | ||
| 776 | } | 808 | } | ||
| 809 | + | case ports.PlaceWindow: | |||
| 810 | + | s.x11Place(c) | |||
| 777 | case ports.ConfigureWindow: | 811 | case ports.ConfigureWindow: | ||
| 778 | w := s.windows[c.ID] | 812 | 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() { |
| 780 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | 814 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("configure missing window") | ||
| 781 | return | 815 | return | ||
| 782 | } | 816 | } | ||
| @@ -787,16 +821,17 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 787 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible | 821 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible | ||
| 788 | w.last, w.hasLast = c, true | 822 | w.last, w.hasLast = c, true | ||
| 789 | if visibilityChanged { | 823 | if visibilityChanged { | ||
| 790 | - | s.relocateCallbacks(w.xdg.surface) | 824 | + | s.relocateCallbacks(w.surf()) |
| 791 | s.dropFeedbacks(time.Now()) | 825 | s.dropFeedbacks(time.Now()) | ||
| 792 | s.wakePacer() // reconsider FIFO barriers at the new period | 826 | s.wakePacer() // reconsider FIFO barriers at the new period | ||
| 793 | } | 827 | } | ||
| 794 | w.sendConfigure() | 828 | w.sendConfigure() | ||
| 829 | + | s.syncKeyboardGrab() | |||
| 795 | s.toplevelChanged(w) | 830 | s.toplevelChanged(w) | ||
| 796 | - | if scanoutChanged && w.xdg.surface != nil { | 831 | + | if scanoutChanged && w.surf() != nil { |
| 797 | - | s.dmabuf.resendSurface(w.xdg.surface) | 832 | + | s.dmabuf.resendSurface(w.surf()) |
| 798 | } | 833 | } | ||
| 799 | - | if surf := w.xdg.surface; surf != nil && c.Output != "" { | 834 | + | if surf := w.surf(); surf != nil && c.Output != "" { |
| 800 | surf.sendTreeScale() | 835 | surf.sendTreeScale() | ||
| 801 | } | 836 | } | ||
| 802 | if scanoutChanged { | 837 | if scanoutChanged { | ||
| @@ -817,10 +852,10 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 817 | s.setShortcutsInhibit(c) | 852 | s.setShortcutsInhibit(c) | ||
| 818 | case ports.CloseWindow: | 853 | case ports.CloseWindow: | ||
| 819 | w := s.windows[c.ID] | 854 | w := s.windows[c.ID] | ||
| 820 | - | if w == nil || w.toplevel == nil || !w.toplevel.Resource.Alive() { | 855 | + | if w == nil || !w.toplevelAlive() { |
| 821 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | 856 | s.log.Debug().Uint64("id", uint64(c.ID)).Msg("close missing window") | ||
| 822 | return | 857 | return | ||
| 823 | } | 858 | } | ||
| 824 | - | w.toplevel.SendClose() | 859 | + | w.sendClose() |
| 825 | } | 860 | } | ||
| 826 | } | 861 | } | ||
M internal/adapters/wayland/surface.go +15 -2
| @@ -28,6 +28,9 @@ const ( | |||||
| 28 | roleSessionLock | 28 | roleSessionLock | ||
| 29 | // roleDragIcon is the icon of a data drag (dnd.go). | 29 | // roleDragIcon is the icon of a data drag (dnd.go). | ||
| 30 | roleDragIcon | 30 | roleDragIcon | ||
| 31 | + | // roleX11 is the surface of an X11 window paired through | |||
| 32 | + | // xwayland_shell_v1. | |||
| 33 | + | roleX11 | |||
| 31 | ) | 34 | ) | ||
| 32 | 35 | ||||
| 33 | type surface struct { | 36 | type surface struct { | ||
| @@ -47,8 +50,12 @@ type surface struct { | |||||
| 47 | formatOpaque bool | 50 | formatOpaque bool | ||
| 48 | kind roleKind | 51 | kind roleKind | ||
| 49 | xdg *xdgSurface | 52 | xdg *xdgSurface | ||
| 50 | - | layer *layerSurface | 53 | + | // x11win is the X11 window the surface is associated with; x11Serial is |
| 51 | - | lock *lockSurface | 54 | + | // the serial of xwayland_surface_v1.set_serial, 0 until set. |
| 55 | + | x11win *window | |||
| 56 | + | x11Serial uint64 | |||
| 57 | + | layer *layerSurface | |||
| 58 | + | lock *lockSurface | |||
| 52 | // bufferCommitted is permanent, including commits later discarded by FIFO. | 59 | // bufferCommitted is permanent, including commits later discarded by FIFO. | ||
| 53 | bufferCommitted bool | 60 | bufferCommitted bool | ||
| 54 | server *Server | 61 | server *Server | ||
| @@ -192,6 +199,9 @@ func (s *surface) windowID() ports.WindowID { | |||||
| 192 | if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped { | 199 | if s.xdg != nil && s.xdg.window != nil && s.xdg.window.mapped { | ||
| 193 | return s.xdg.window.id | 200 | return s.xdg.window.id | ||
| 194 | } | 201 | } | ||
| 202 | + | if s.x11win != nil && s.x11win.mapped { | |||
| 203 | + | return s.x11win.id | |||
| 204 | + | } | |||
| 195 | if s.layer != nil && s.layer.mapped { | 205 | if s.layer != nil && s.layer.mapped { | ||
| 196 | return s.layer.id | 206 | return s.layer.id | ||
| 197 | } | 207 | } | ||
| @@ -566,6 +576,9 @@ func (s *surface) applyCommit(u *update) { | |||||
| 566 | if s.xdg != nil && s.xdg.window != nil { | 576 | if s.xdg != nil && s.xdg.window != nil { | ||
| 567 | s.xdg.window.afterCommit() | 577 | s.xdg.window.afterCommit() | ||
| 568 | } | 578 | } | ||
| 579 | + | if s.x11win != nil { | |||
| 580 | + | s.x11win.afterCommit() | |||
| 581 | + | } | |||
| 569 | if s.lock != nil { | 582 | if s.lock != nil { | ||
| 570 | // Role callbacks precede content construction; publish lock placement | 583 | // Role callbacks precede content construction; publish lock placement | ||
| 571 | // only once validated current dimensions are available. | 584 | // only once validated current dimensions are available. | ||
A internal/adapters/wayland/window.go +112 -0
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/go-wayland-bindings/server/wayland" | |||
| 5 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | + | "github.com/bnema/purego-libwayland/server" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Role-independent accessors of a window. Code outside xdg.go goes through | |||
| 10 | + | // them instead of dereferencing w.xdg, which is nil for windows of another | |||
| 11 | + | // role. | |||
| 12 | + | ||||
| 13 | + | // isToplevel reports whether w is a toplevel (xdg or X11), not a popup. | |||
| 14 | + | func (w *window) isToplevel() bool { return w.toplevel != nil || (w.x11 != nil && !w.x11.override) } | |||
| 15 | + | ||||
| 16 | + | // srv is the server the window belongs to. | |||
| 17 | + | func (w *window) srv() *Server { | |||
| 18 | + | if w.xdg != nil { | |||
| 19 | + | return w.xdg.server | |||
| 20 | + | } | |||
| 21 | + | return w.x11.server | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // surf is the window's surface, nil when it has none. | |||
| 25 | + | func (w *window) surf() *surface { | |||
| 26 | + | switch { | |||
| 27 | + | case w.xdg != nil: | |||
| 28 | + | return w.xdg.surface | |||
| 29 | + | case w.x11 != nil: | |||
| 30 | + | return w.x11.surface | |||
| 31 | + | } | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // alive reports whether the window's role object is alive. | |||
| 36 | + | func (w *window) alive() bool { | |||
| 37 | + | switch { | |||
| 38 | + | case w.xdg != nil: | |||
| 39 | + | return w.xdg.resource.Resource.Alive() | |||
| 40 | + | case w.x11 != nil: | |||
| 41 | + | return w.x11.surface != nil && w.x11.surface.wl.Resource.Alive() | |||
| 42 | + | } | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // client is the window's client; the zero Client when it has no role object. | |||
| 47 | + | func (w *window) client() server.Client { | |||
| 48 | + | switch { | |||
| 49 | + | case w.xdg != nil: | |||
| 50 | + | return w.xdg.resource.Client() | |||
| 51 | + | case w.x11 != nil && w.x11.surface != nil: | |||
| 52 | + | return w.x11.surface.wl.Client() | |||
| 53 | + | } | |||
| 54 | + | return server.Client{} | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // geometry is the committed window geometry; empty means the whole surface. | |||
| 58 | + | func (w *window) geometry() ports.Rect { | |||
| 59 | + | if w.xdg != nil { | |||
| 60 | + | return w.xdg.geometry | |||
| 61 | + | } | |||
| 62 | + | return ports.Rect{} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // sendConfigure sends w.last to the client. | |||
| 66 | + | func (w *window) sendConfigure() { | |||
| 67 | + | switch { | |||
| 68 | + | case w.xdg != nil: | |||
| 69 | + | w.sendXDGConfigure() | |||
| 70 | + | case w.x11 != nil: | |||
| 71 | + | w.x11.server.x11Configure(w) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // sendClose asks the client to close the window. | |||
| 76 | + | func (w *window) sendClose() { | |||
| 77 | + | switch { | |||
| 78 | + | case w.toplevel != nil: | |||
| 79 | + | w.toplevel.SendClose() | |||
| 80 | + | case w.x11 != nil: | |||
| 81 | + | w.x11.server.x11Close(w) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // toplevelAlive reports whether the window is a toplevel whose protocol | |||
| 86 | + | // object is alive. | |||
| 87 | + | func (w *window) toplevelAlive() bool { | |||
| 88 | + | if w.x11 != nil { | |||
| 89 | + | return w.alive() | |||
| 90 | + | } | |||
| 91 | + | return w.toplevel != nil && w.toplevel.Resource.Alive() | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // surfaceResource is the window's wl_surface, nil when it has none or it died. | |||
| 95 | + | func (w *window) surfaceResource() *wayland.Surface { | |||
| 96 | + | switch { | |||
| 97 | + | case w.xdg != nil: | |||
| 98 | + | return w.xdg.surfaceResource() | |||
| 99 | + | case w.x11 != nil && w.alive(): | |||
| 100 | + | return w.x11.surface.wl | |||
| 101 | + | } | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // toplevelWindow is the toplevel window (xdg or X11) the surface is the role | |||
| 106 | + | // surface of, nil for other roles and popups' parents are not followed. | |||
| 107 | + | func (s *surface) toplevelWindow() *window { | |||
| 108 | + | if s.xdg != nil && s.xdg.window != nil { | |||
| 109 | + | return s.xdg.window | |||
| 110 | + | } | |||
| 111 | + | return s.x11win | |||
| 112 | + | } | |||
M internal/adapters/wayland/wire_client_test.go +6 -0
| @@ -55,6 +55,8 @@ import ( | |||||
| 55 | wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign" | 55 | wire_xdgforeign "github.com/bnema/go-wayland-bindings/server/xdgforeign" | ||
| 56 | wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput" | 56 | wire_xdgoutput "github.com/bnema/go-wayland-bindings/server/xdgoutput" | ||
| 57 | wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell" | 57 | wire_xdgshell "github.com/bnema/go-wayland-bindings/server/xdgshell" | ||
| 58 | + | wire_xwaylandkeyboardgrab "github.com/bnema/go-wayland-bindings/server/xwaylandkeyboardgrab" | |||
| 59 | + | wire_xwaylandshell "github.com/bnema/go-wayland-bindings/server/xwaylandshell" | |||
| 58 | wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | 60 | wire_imagecapture "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture" | ||
| 59 | "github.com/bnema/purego-libwayland/server" | 61 | "github.com/bnema/purego-libwayland/server" | ||
| 60 | "github.com/bnema/wlturbo" | 62 | "github.com/bnema/wlturbo" | ||
| @@ -253,6 +255,10 @@ var wireSchemas = map[string]wireSchema{ | |||||
| 253 | "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil}, | 255 | "zwp_text_input_v3": {wire_textinput.ZwpTextInputV3Interface, &client_textinput.TextInputV3{}, map[uint16]bool{0: true}, nil}, | ||
| 254 | "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil}, | 256 | "zwp_virtual_keyboard_manager_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardManagerV1Interface, &client_virtualkeyboard.VirtualKeyboardManager{}, nil, nil}, | ||
| 255 | "zwp_virtual_keyboard_v1": {wire_virtualkeyboard.ZwpVirtualKeyboardV1Interface, &client_virtualkeyboard.VirtualKeyboard{}, map[uint16]bool{3: true}, nil}, | 257 | "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}, | |||
| 256 | "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil}, | 262 | "zxdg_output_manager_v1": {wire_xdgoutput.ZxdgOutputManagerV1Interface, &client_xdgoutput.ZxdgOutputManager{}, map[uint16]bool{0: true}, nil}, | ||
| 257 | "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil}, | 263 | "zxdg_output_v1": {wire_xdgoutput.ZxdgOutputV1Interface, &client_xdgoutput.ZxdgOutput{}, map[uint16]bool{0: true}, nil}, | ||
| 258 | } | 264 | } | ||
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 +458 -0
| @@ -0,0 +1,458 @@ | |||||
| 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, Fullscreen: x.info.Fullscreen}) | |||
| 315 | + | s.syncInhibitors() | |||
| 316 | + | s.toplevelChanged(w) | |||
| 317 | + | s.castInventoryChanged() | |||
| 318 | + | 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") | |||
| 319 | + | // Called from a commit, the content follows right after; called from the | |||
| 320 | + | // pairing, the surface already shows a buffer and is published now. | |||
| 321 | + | if surf.has { | |||
| 322 | + | surf.resetInputEmission() | |||
| 323 | + | surf.redraw() | |||
| 324 | + | surf.emitInput() | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // OverrideMapped starts tracking an override-redirect window. It is announced | |||
| 329 | + | // to core on the paired surface's first buffer commit, so it is never drawn | |||
| 330 | + | // without content. | |||
| 331 | + | func (h hostImpl) OverrideMapped(slot xwm.Slot, xid uint32, surfaceID uint64, x, y, w, h2 int, noInput bool) { | |||
| 332 | + | s := h.s | |||
| 333 | + | surf := s.surfaceByIdentity[surfaceID] | |||
| 334 | + | if surf == nil || surf.kind != roleX11 || surf.x11win != nil { | |||
| 335 | + | return | |||
| 336 | + | } | |||
| 337 | + | if old := s.x11Slots[slot]; old != nil { | |||
| 338 | + | h.OverrideGone(slot) | |||
| 339 | + | } | |||
| 340 | + | win := &window{id: s.nextWindow} | |||
| 341 | + | win.x11 = &x11Window{server: s, slot: slot, xid: xid, override: true, noInput: noInput, rect: ports.Rect{X: x, Y: y, W: w, H: h2}} | |||
| 342 | + | s.nextWindow++ | |||
| 343 | + | s.windows[win.id] = win | |||
| 344 | + | s.x11Slots[slot] = win | |||
| 345 | + | ox := win.x11 | |||
| 346 | + | ox.surface, surf.x11win = surf, win | |||
| 347 | + | surf.role = func(buffer bool) { | |||
| 348 | + | switch { | |||
| 349 | + | case surf.destroyed: | |||
| 350 | + | ox.hide() | |||
| 351 | + | ox.surface = nil | |||
| 352 | + | surf.x11win = nil | |||
| 353 | + | case buffer: | |||
| 354 | + | ox.show() | |||
| 355 | + | case win.mapped: | |||
| 356 | + | ox.hide() | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ox.show() | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // OverrideMoved records the new layout rect of an override window and, once | |||
| 363 | + | // shown, tells core. An unchanged rect costs nothing. | |||
| 364 | + | func (h hostImpl) OverrideMoved(slot xwm.Slot, x, y, w, h2 int) { | |||
| 365 | + | win := h.s.x11Slots[slot] | |||
| 366 | + | if win == nil || win.x11 == nil || !win.x11.override { | |||
| 367 | + | return | |||
| 368 | + | } | |||
| 369 | + | r := ports.Rect{X: x, Y: y, W: w, H: h2} | |||
| 370 | + | if r == win.x11.rect { | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 373 | + | win.x11.rect = r | |||
| 374 | + | if win.mapped { | |||
| 375 | + | h.s.emit(ports.UnmanagedMoved{ID: win.id, X: x, Y: y, Width: w, Height: h2}) | |||
| 376 | + | win.x11.surface.sendTreeScale() | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // OverrideGone ends an override window: unmapped, or destroyed. | |||
| 381 | + | func (h hostImpl) SelectionOwned(kind int, mimes []string) { | |||
| 382 | + | h.s.log.Debug().Int("kind", kind).Int("mimes", len(mimes)).Msg("X selection owner changed") | |||
| 383 | + | h.s.setXSelection(kind, mimes) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func (h hostImpl) SelectionRead(kind int, mime string) int { | |||
| 387 | + | return h.s.xSelectionRead(kind, mime) | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func (h hostImpl) After(d time.Duration, fn func()) func() { | |||
| 391 | + | t := h.s.clock.AfterFunc(d, func() { h.s.display.Post(fn) }) | |||
| 392 | + | return func() { t.Stop() } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func (h hostImpl) OverrideGone(slot xwm.Slot) { | |||
| 396 | + | s := h.s | |||
| 397 | + | win := s.x11Slots[slot] | |||
| 398 | + | if win == nil || win.x11 == nil || !win.x11.override { | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | win.x11.hide() | |||
| 402 | + | win.x11.drop() | |||
| 403 | + | delete(s.windows, win.id) | |||
| 404 | + | delete(s.x11Slots, slot) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // show announces the window to core once its surface has content. | |||
| 408 | + | func (x *x11Window) show() { | |||
| 409 | + | surf := x.surface | |||
| 410 | + | if surf == nil || surf.x11win == nil || surf.destroyed || surf.current == nil { | |||
| 411 | + | return | |||
| 412 | + | } | |||
| 413 | + | w := surf.x11win | |||
| 414 | + | if w.mapped { | |||
| 415 | + | return | |||
| 416 | + | } | |||
| 417 | + | s := x.server | |||
| 418 | + | w.mapped = true | |||
| 419 | + | r := x.rect | |||
| 420 | + | s.emit(ports.UnmanagedMapped{ID: w.id, X: r.X, Y: r.Y, Width: r.W, Height: r.H, NoInput: x.noInput}) | |||
| 421 | + | surf.sendTreeScale() | |||
| 422 | + | if surf.has { | |||
| 423 | + | surf.resetInputEmission() | |||
| 424 | + | surf.redraw() | |||
| 425 | + | surf.emitInput() | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | // hide withdraws a shown override window from core and the seat. | |||
| 430 | + | func (x *x11Window) hide() { | |||
| 431 | + | if x.surface == nil || x.surface.x11win == nil { | |||
| 432 | + | return | |||
| 433 | + | } | |||
| 434 | + | w := x.surface.x11win | |||
| 435 | + | s := x.server | |||
| 436 | + | if s.seat.pointerFocus == w.id { | |||
| 437 | + | s.changePointerFocus(0, 0, 0) | |||
| 438 | + | } | |||
| 439 | + | if !w.mapped { | |||
| 440 | + | return | |||
| 441 | + | } | |||
| 442 | + | w.mapped = false | |||
| 443 | + | x.surface.resetInputEmission() | |||
| 444 | + | s.emit(ports.UnmanagedUnmapped{ID: w.id}) | |||
| 445 | + | s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true}) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // isOverride reports whether w is an override-redirect X11 window. | |||
| 449 | + | func (w *window) isOverride() bool { return w.x11 != nil && w.x11.override } | |||
| 450 | + | ||||
| 451 | + | // overrideOutput is the name of the output under the origin of an override | |||
| 452 | + | // window's rect, "" when none. | |||
| 453 | + | func (s *Server) overrideOutput(w *window) string { | |||
| 454 | + | if o, ok := s.outputPlaces.At(float64(w.x11.rect.X), float64(w.x11.rect.Y)); ok { | |||
| 455 | + | return o.Info.Name | |||
| 456 | + | } | |||
| 457 | + | return "" | |||
| 458 | + | } | |||
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) { | |||||
| 128 | 128 | ||||
| 129 | // window is an xdg_toplevel, or an xdg_popup when popup is set. | 129 | // window is an xdg_toplevel, or an xdg_popup when popup is set. | ||
| 130 | type window struct { | 130 | type window struct { | ||
| 131 | - | id ports.WindowID | 131 | + | id ports.WindowID |
| 132 | - | toplevel *xdgshell.Toplevel | 132 | + | toplevel *xdgshell.Toplevel |
| 133 | - | popup *popup | 133 | + | popup *popup |
| 134 | - | xdg *xdgSurface | 134 | + | xdg *xdgSurface |
| 135 | + | // x11 is set for the window of an X11 client (x11window.go). | |||
| 136 | + | x11 *x11Window | |||
| 135 | appID, title string | 137 | appID, title string | ||
| 136 | mapped bool | 138 | mapped bool | ||
| 137 | // gen counts the mappings: an unmap and a remap is a new toplevel to | 139 | // gen counts the mappings: an unmap and a remap is a new toplevel to | ||
| @@ -151,8 +153,8 @@ type window struct { | |||||
| 151 | hasLast bool | 153 | hasLast bool | ||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 154 | - | // sendConfigure sends w.last as xdg_toplevel + xdg_surface configure. | 156 | + | // sendXDGConfigure sends w.last as xdg_toplevel + xdg_surface configure. |
| 155 | - | func (w *window) sendConfigure() { | 157 | + | func (w *window) sendXDGConfigure() { |
| 156 | c := w.last | 158 | c := w.last | ||
| 157 | w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version())) | 159 | w.toplevel.SendConfigure(int32(c.Width), int32(c.Height), toplevelStates(c, w.toplevel.Version())) | ||
| 158 | s := w.xdg.server | 160 | s := w.xdg.server | ||
| @@ -192,24 +194,29 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte { | |||||
| 192 | } | 194 | } | ||
| 193 | 195 | ||||
| 194 | func (w *window) unmap() { | 196 | func (w *window) unmap() { | ||
| 195 | - | if w.xdg.server.seat.pointerFocus == w.id { | 197 | + | s := w.srv() |
| 196 | - | w.xdg.server.changePointerFocus(0, 0, 0) | 198 | + | if s.seat.pointerFocus == w.id { |
| 199 | + | s.changePointerFocus(0, 0, 0) | |||
| 197 | } | 200 | } | ||
| 198 | - | if w.xdg.server.seat.focused == w.id { | 201 | + | if s.seat.focused == w.id { |
| 199 | - | w.xdg.server.changeFocus(0) | 202 | + | s.changeFocus(0) |
| 200 | } | 203 | } | ||
| 201 | if !w.mapped { | 204 | if !w.mapped { | ||
| 202 | return | 205 | return | ||
| 203 | } | 206 | } | ||
| 204 | w.mapped = false | 207 | w.mapped = false | ||
| 205 | - | w.xdg.server.toplevelClosed(w.id) | 208 | + | s.toplevelClosed(w.id) |
| 206 | - | w.xdg.surface.resetInputEmission() | 209 | + | if surf := w.surf(); surf != nil { |
| 207 | - | w.xdg.configured = false | 210 | + | surf.resetInputEmission() |
| 208 | - | w.xdg.acked = false | 211 | + | } |
| 209 | - | w.xdg.serials = nil | 212 | + | if x := w.xdg; x != nil { |
| 210 | - | w.xdg.server.emit(ports.WindowUnmapped{ID: w.id}) | 213 | + | x.configured = false |
| 211 | - | w.xdg.server.syncInhibitors() | 214 | + | x.acked = false |
| 212 | - | w.xdg.server.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true}) | 215 | + | x.serials = nil |
| 216 | + | } | |||
| 217 | + | s.emit(ports.WindowUnmapped{ID: w.id}) | |||
| 218 | + | s.syncInhibitors() | |||
| 219 | + | s.emitContent(ports.SurfaceContent{ID: w.id}, damage{full: true}) | |||
| 213 | } | 220 | } | ||
| 214 | func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | 221 | func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | ||
| 215 | if x.window != nil { | 222 | if x.window != nil { | ||
| @@ -337,7 +344,7 @@ func (w *window) setParent(p *window) { | |||||
| 337 | if p != nil && p.mapped { | 344 | if p != nil && p.mapped { | ||
| 338 | id = p.id | 345 | id = p.id | ||
| 339 | } | 346 | } | ||
| 340 | - | w.xdg.server.emit(ports.WindowParent{ID: w.id, Parent: id}) | 347 | + | w.srv().emit(ports.WindowParent{ID: w.id, Parent: id}) |
| 341 | } | 348 | } | ||
| 342 | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | 349 | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { | ||
| 343 | if title == t.w.title { | 350 | if title == t.w.title { | ||
| @@ -417,7 +424,7 @@ func (w *window) afterCommit() { | |||||
| 417 | } | 424 | } | ||
| 418 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | 425 | if fw, fh := w.size(); fw > 0 && fh > 0 && (fw != w.floatW || fh != w.floatH) { | ||
| 419 | w.floatW, w.floatH = fw, fh | 426 | w.floatW, w.floatH = fw, fh | ||
| 420 | - | w.xdg.server.emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) | 427 | + | w.srv().emit(ports.WindowResized{ID: w.id, Width: fw, Height: fh}) |
| 421 | } | 428 | } | ||
| 422 | } | 429 | } | ||
| 423 | 430 | ||||
| @@ -431,6 +438,10 @@ func (w *window) floats() bool { | |||||
| 431 | 438 | ||||
| 432 | // size is the window geometry size, else the surface size, in logical pixels. | 439 | // size is the window geometry size, else the surface size, in logical pixels. | ||
| 433 | func (w *window) size() (int, int) { | 440 | func (w *window) size() (int, int) { | ||
| 441 | + | if w.xdg == nil { | |||
| 442 | + | c := w.surf().content | |||
| 443 | + | return c.LogicalW, c.LogicalH | |||
| 444 | + | } | |||
| 434 | if g := w.xdg.geometry; g.W > 0 && g.H > 0 { | 445 | if g := w.xdg.geometry; g.W > 0 && g.H > 0 { | ||
| 435 | return g.W, g.H | 446 | return g.W, g.H | ||
| 436 | } | 447 | } | ||
| @@ -441,7 +452,7 @@ func (w *window) size() (int, int) { | |||||
| 441 | // surfacePoint turns window coordinates (core's, from the geometry origin) | 452 | // surfacePoint turns window coordinates (core's, from the geometry origin) | ||
| 442 | // into surface coordinates. | 453 | // into surface coordinates. | ||
| 443 | func (w *window) surfacePoint(x, y float64) (float64, float64) { | 454 | func (w *window) surfacePoint(x, y float64) (float64, float64) { | ||
| 444 | - | g := w.xdg.geometry | 455 | + | g := w.geometry() |
| 445 | return x + float64(g.X), y + float64(g.Y) | 456 | return x + float64(g.X), y + float64(g.Y) | ||
| 446 | } | 457 | } | ||
| 447 | 458 | ||||
A internal/adapters/wayland/xdgoutputx_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-libwayland/server" | |||
| 9 | + | "github.com/bnema/wlturbo" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // xdgPosProxy records the logical_position events of one zxdg_output_v1. | |||
| 13 | + | type xdgPosProxy struct { | |||
| 14 | + | wlturbo.BaseProxy | |||
| 15 | + | pos [][2]int32 | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (p *xdgPosProxy) Dispatch(e *wlturbo.Event) { | |||
| 19 | + | if e.Opcode == 0 { | |||
| 20 | + | p.pos = append(p.pos, [2]int32{e.Int32(), e.Int32()}) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // xdgOutputsOf binds the xdg-output manager and an xdg-output for every | |||
| 25 | + | // wl_output of c, and returns what each one reported. | |||
| 26 | + | func xdgOutputsOf(t *testing.T, c *wlturbo.Display) []*xdgPosProxy { | |||
| 27 | + | t.Helper() | |||
| 28 | + | if err := c.Roundtrip(); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | var mgrs int | |||
| 32 | + | var manager uint32 | |||
| 33 | + | for _, g := range c.Registry().GetGlobals() { | |||
| 34 | + | if g.Interface == "zxdg_output_manager_v1" { | |||
| 35 | + | mgrs++ | |||
| 36 | + | id, err := bindWireID(c, g.Name, g.Interface, 3) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | registerProtocol(t, c, id) | |||
| 41 | + | manager = id | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | if mgrs != 1 { | |||
| 45 | + | t.Fatalf("client sees %d zxdg_output_manager_v1 globals, want 1", mgrs) | |||
| 46 | + | } | |||
| 47 | + | var out []*xdgPosProxy | |||
| 48 | + | for _, g := range c.Registry().GetGlobals() { | |||
| 49 | + | if g.Interface != "wl_output" { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | wo, err := bindWireID(c, g.Name, g.Interface, 4) | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | xp := &xdgPosProxy{} | |||
| 57 | + | xp.SetID(c.AllocateID()) | |||
| 58 | + | registerWireProxy(c, xp) | |||
| 59 | + | requestProtocol(t, c, manager, 1, xp.ID(), wo) | |||
| 60 | + | out = append(out, xp) | |||
| 61 | + | } | |||
| 62 | + | if err := c.Roundtrip(); err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | return out | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func lastPositions(ps []*xdgPosProxy) [][2]int32 { | |||
| 69 | + | var r [][2]int32 | |||
| 70 | + | for _, p := range ps { | |||
| 71 | + | r = append(r, p.pos[len(p.pos)-1]) | |||
| 72 | + | } | |||
| 73 | + | slices.SortFunc(r, func(a, b [2]int32) int { return int(a[0] - b[0]) }) | |||
| 74 | + | return r | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func twoOutputs(ax int) ports.SetOutputs { | |||
| 78 | + | mk := func(name string, x int) ports.OutputPlacement { | |||
| 79 | + | return ports.OutputPlacement{Info: ports.OutputInfo{Name: name, Width: 1920, Height: 1080}, X: x, Y: 0, Width: 1920, Height: 1080, Scale: 1} | |||
| 80 | + | } | |||
| 81 | + | return ports.SetOutputs{Outputs: ports.Layout{mk("A", ax), mk("B", 0)}} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Xwayland gets X-space positions (layout top-left is 0,0) and re-receives | |||
| 85 | + | // them on a layout change; every other client keeps the layout coordinates. | |||
| 86 | + | func TestXwaylandPrivateXDGOutput(t *testing.T) { | |||
| 87 | + | r := newX11Rig(t) | |||
| 88 | + | applyOutputs(t, r.s, r.commands, twoOutputs(-1920)) | |||
| 89 | + | other := protocolClient(t, r.s, r.dir) | |||
| 90 | + | xo := xdgOutputsOf(t, r.xc) | |||
| 91 | + | oo := xdgOutputsOf(t, other) | |||
| 92 | + | if got, want := lastPositions(xo), [][2]int32{{0, 0}, {1920, 0}}; !slices.Equal(got, want) { | |||
| 93 | + | t.Errorf("Xwayland positions %v, want %v", got, want) | |||
| 94 | + | } | |||
| 95 | + | if got, want := lastPositions(oo), [][2]int32{{-1920, 0}, {0, 0}}; !slices.Equal(got, want) { | |||
| 96 | + | t.Errorf("ordinary positions %v, want %v", got, want) | |||
| 97 | + | } | |||
| 98 | + | applyOutputs(t, r.s, r.commands, twoOutputs(-3840)) | |||
| 99 | + | if err := r.xc.Roundtrip(); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | if err := other.Roundtrip(); err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if got, want := lastPositions(xo), [][2]int32{{0, 0}, {3840, 0}}; !slices.Equal(got, want) { | |||
| 106 | + | t.Errorf("Xwayland positions after change %v, want %v", got, want) | |||
| 107 | + | } | |||
| 108 | + | if got, want := lastPositions(oo), [][2]int32{{-3840, 0}, {0, 0}}; !slices.Equal(got, want) { | |||
| 109 | + | t.Errorf("ordinary positions after change %v, want %v", got, want) | |||
| 110 | + | } | |||
| 111 | + | // Hotplug: a third output on the right grows the extents. | |||
| 112 | + | third := twoOutputs(-3840) | |||
| 113 | + | third.Outputs = append(third.Outputs, ports.OutputPlacement{Info: ports.OutputInfo{Name: "C", Width: 1920, Height: 1080}, X: 1920, Width: 1920, Height: 1080, Scale: 1}) | |||
| 114 | + | applyOutputs(t, r.s, r.commands, third) | |||
| 115 | + | if err := r.xc.Roundtrip(); err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | if got := lastPositions(xo); len(got) != 2 || got[1] != [2]int32{3840, 0} { | |||
| 119 | + | t.Errorf("Xwayland positions after hotplug %v", got) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestGlobalFilterAllocations(t *testing.T) { | |||
| 124 | + | if raceEnabled { | |||
| 125 | + | t.Skip("the race detector allocates in the background") | |||
| 126 | + | } | |||
| 127 | + | s := &Server{xdgPublic: &server.Global{}, xdgPrivate: &server.Global{}} | |||
| 128 | + | if allocs := testing.AllocsPerRun(100, func() { | |||
| 129 | + | s.globalVisible(server.Client{}, s.xdgPrivate) | |||
| 130 | + | s.globalVisible(server.Client{}, s.xdgPublic) | |||
| 131 | + | }); allocs != 0 { | |||
| 132 | + | t.Errorf("globalVisible: %.1f allocs, want 0", allocs) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // The X origin is derived by one Space, kept without a window manager. | |||
| 137 | + | func TestXSpaceFollowsLayoutWithoutXWM(t *testing.T) { | |||
| 138 | + | r := newX11Rig(t) | |||
| 139 | + | applyOutputs(t, r.s, r.commands, twoOutputs(-1920)) | |||
| 140 | + | if ox, oy := r.s.xspace.Origin(); ox != -1920 || oy != 0 { | |||
| 141 | + | t.Fatalf("origin %d,%d, want -1920,0", ox, oy) | |||
| 142 | + | } | |||
| 143 | + | } | |||
A internal/adapters/wayland/xwayland.go +75 -0
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/wayland/xwm" | |||
| 8 | + | "github.com/bnema/purego-libwayland/server" | |||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // xwaylandClient belongs to the display goroutine. | |||
| 13 | + | type xwaylandClient struct { | |||
| 14 | + | client server.Client | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // AttachXwayland transfers ownership of the Wayland descriptor to the display. | |||
| 18 | + | // It may be called from any goroutine. | |||
| 19 | + | func (s *Server) AttachXwayland(wlFD int) { | |||
| 20 | + | ran := s.display.Do(func() { | |||
| 21 | + | c, err := s.display.CreateClient(wlFD) | |||
| 22 | + | if err != nil { | |||
| 23 | + | s.log.Warn().Err(err).Msg("Xwayland connection not adopted") | |||
| 24 | + | _ = unix.Close(wlFD) // CreateClient only adopts on success. | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | s.forgetXwayland() | |||
| 28 | + | s.xwayland = xwaylandClient{client: c} | |||
| 29 | + | c.OnDestroy(func() { | |||
| 30 | + | if s.xwayland.client == c { | |||
| 31 | + | s.teardownXWM() | |||
| 32 | + | s.forgetXwayland() | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | s.log.Info().Msg("Xwayland attached") | |||
| 36 | + | }) | |||
| 37 | + | if !ran { | |||
| 38 | + | _ = unix.Close(wlFD) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // forgetXwayland runs on the display owner, or after Run has joined it. | |||
| 43 | + | func (s *Server) forgetXwayland() { | |||
| 44 | + | if s.xwayland.client != (server.Client{}) { | |||
| 45 | + | s.xwayland = xwaylandClient{} | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // AttachXWM bootstraps the X11 window manager on the window manager | |||
| 50 | + | // descriptor, which it owns. It runs on the starter goroutine once Xwayland is | |||
| 51 | + | // ready and blocks until the XWM is installed, ctx ends or Boot fails. stop | |||
| 52 | + | // closes the XWM on the display goroutine and waits for it. | |||
| 53 | + | func (s *Server) AttachXWM(ctx context.Context, wmFD int) (stop func()) { | |||
| 54 | + | log := logging.For(ctx, "xwm") | |||
| 55 | + | x, err := xwm.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 +317 -0
| @@ -0,0 +1,317 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "bnema.dev/xturbo" | |||
| 7 | + | "bnema.dev/xturbo/xproto" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // maxClientSize bounds the size a floating window may ask for. | |||
| 12 | + | const maxClientSize = 16384 | |||
| 13 | + | ||||
| 14 | + | // _NET_WM_STATE bits tracked in record.stateSent. | |||
| 15 | + | const ( | |||
| 16 | + | stateFullscreen uint8 = 1 << iota | |||
| 17 | + | stateFocused | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // size is the size to send: the requested one, else the last known. | |||
| 21 | + | func (r *record) size() (w, h uint32) { | |||
| 22 | + | w, h = r.pend.w, r.pend.h | |||
| 23 | + | if w == 0 { | |||
| 24 | + | w = r.w | |||
| 25 | + | } | |||
| 26 | + | if h == 0 { | |||
| 27 | + | h = r.h | |||
| 28 | + | } | |||
| 29 | + | return w, h | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // differs reports whether the pending configure is not what the X server has. | |||
| 33 | + | func (r *record) differs() bool { | |||
| 34 | + | w, h := r.size() | |||
| 35 | + | p, s := &r.pend, &r.sent | |||
| 36 | + | return p.x != s.x || p.y != s.y || w != s.w || h != s.h || | |||
| 37 | + | p.fullscreen != s.fullscreen || p.activated != s.activated | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // markDirty queues s for the next flush when its pending configure differs. | |||
| 41 | + | func (x *XWM) markDirty(s Slot, r *record) { | |||
| 42 | + | if r.pend.dirty || !r.differs() { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | if len(x.dirty) == cap(x.dirty) { | |||
| 46 | + | // Stale entries of freed and reused slots filled the queue: send | |||
| 47 | + | // what is queued rather than grow it. r stays valid, nothing here | |||
| 48 | + | // grows the table. | |||
| 49 | + | x.flushConfigures() | |||
| 50 | + | } | |||
| 51 | + | r.pend.dirty = true | |||
| 52 | + | x.dirty = append(x.dirty, s) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // managedRec returns the record of a live managed window. | |||
| 56 | + | func (x *XWM) managedRec(s Slot) *record { | |||
| 57 | + | if x.closed { | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | if r := x.tab.rec(s); r != nil && r.kind == kindManaged { | |||
| 61 | + | return r | |||
| 62 | + | } | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Configure records the size and state core wants for s; sent at flush. A | |||
| 67 | + | // zero size keeps the current one. | |||
| 68 | + | func (x *XWM) Configure(s Slot, c ports.ConfigureWindow) { | |||
| 69 | + | r := x.managedRec(s) | |||
| 70 | + | if r == nil { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | if c.Width > 0 { | |||
| 74 | + | r.pend.w = uint32(min(c.Width, maxClientSize)) | |||
| 75 | + | } | |||
| 76 | + | if c.Height > 0 { | |||
| 77 | + | r.pend.h = uint32(min(c.Height, maxClientSize)) | |||
| 78 | + | } | |||
| 79 | + | r.pend.fullscreen, r.pend.activated = c.Fullscreen, c.Activated | |||
| 80 | + | x.markDirty(s, r) | |||
| 81 | + | x.wake() | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Place records the layout position of the client rectangle of s. | |||
| 85 | + | func (x *XWM) Place(s Slot, px, py int) { | |||
| 86 | + | r := x.managedRec(s) | |||
| 87 | + | if r == nil { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | r.lx, r.ly, r.placed = int32(px), int32(py), true | |||
| 91 | + | r.pend.x, r.pend.y = x.toX(r) | |||
| 92 | + | x.markDirty(s, r) | |||
| 93 | + | x.wake() | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // toX converts the layout position of r to X coordinates. | |||
| 97 | + | func (x *XWM) toX(r *record) (px, py int32) { | |||
| 98 | + | ix, iy := x.space.ToX(int(r.lx), int(r.ly)) | |||
| 99 | + | return int32(ix), int32(iy) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // SetLayout installs the output layout that X coordinates derive from. When it | |||
| 103 | + | // changes, placed managed windows are repositioned and mapped override windows | |||
| 104 | + | // are re-reported in layout coordinates. Display goroutine only. | |||
| 105 | + | func (x *XWM) SetLayout(l ports.Layout) { | |||
| 106 | + | if x.closed { | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | x.LayoutChanged(x.space.Set(l)) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // UseSpace makes sp the coordinate mapping, owned by the host, which calls | |||
| 113 | + | // Set on it and then LayoutChanged. Call before Attach. | |||
| 114 | + | func (x *XWM) UseSpace(sp *Space) { x.space = sp } | |||
| 115 | + | ||||
| 116 | + | // LayoutChanged repositions windows after the shared Space was set; changed | |||
| 117 | + | // is Set's result. Display goroutine only. | |||
| 118 | + | func (x *XWM) LayoutChanged(changed bool) { | |||
| 119 | + | if x.closed { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | if n := x.space.Dropped(); n > 0 && !x.warnedSpace { | |||
| 123 | + | x.warnedSpace = true | |||
| 124 | + | x.log.Warn().Int("dropped", n).Int("max", maxRegions).Msg("more outputs than the X11 layout holds, extra outputs ignored") | |||
| 125 | + | } | |||
| 126 | + | if !changed { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | for i := range x.tab.recs { | |||
| 130 | + | r, s := &x.tab.recs[i], Slot(i+1) | |||
| 131 | + | switch { | |||
| 132 | + | case r.kind == kindManaged && r.placed: | |||
| 133 | + | r.pend.x, r.pend.y = x.toX(r) | |||
| 134 | + | x.markDirty(s, r) | |||
| 135 | + | case r.kind == kindUnmanaged && r.ovPaired: | |||
| 136 | + | x.queueOverride(s, r) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | x.wake() | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // flushConfigures sends the pending configures, once per display iteration, | |||
| 143 | + | // then what focus left to do. | |||
| 144 | + | func (x *XWM) flushConfigures() { | |||
| 145 | + | for _, s := range x.dirty { | |||
| 146 | + | if r := x.tab.rec(s); r != nil && r.kind == kindManaged && r.pend.dirty { | |||
| 147 | + | x.sendPending(s, r) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | x.dirty = x.dirty[:0] | |||
| 151 | + | x.flushOverrides() | |||
| 152 | + | x.flushFocus() | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // sendPending writes the geometry (with the synthetic ConfigureNotify ICCCM | |||
| 156 | + | // 4.1.5 asks for, since Xwayland may not move the window) and the | |||
| 157 | + | // _NET_WM_STATE bits that changed. | |||
| 158 | + | func (x *XWM) sendPending(s Slot, r *record) { | |||
| 159 | + | r.pend.dirty = false | |||
| 160 | + | p := &r.pend | |||
| 161 | + | w, h := r.size() | |||
| 162 | + | if w > 0 && h > 0 && (p.x != r.sent.x || p.y != r.sent.y || w != r.sent.w || h != r.sent.h) { | |||
| 163 | + | var v xproto.ConfigureWindowValues | |||
| 164 | + | v.SetX(p.x).SetY(p.y).SetWidth(w).SetHeight(h).SetBorderWidth(0) | |||
| 165 | + | x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0) | |||
| 166 | + | r.x, r.y, r.w, r.h = p.x, p.y, w, h | |||
| 167 | + | x.sendConfigureNotify(r) | |||
| 168 | + | r.sent.x, r.sent.y, r.sent.w, r.sent.h = p.x, p.y, w, h | |||
| 169 | + | } | |||
| 170 | + | var bits uint8 | |||
| 171 | + | if p.fullscreen { | |||
| 172 | + | bits |= stateFullscreen | |||
| 173 | + | } | |||
| 174 | + | if p.activated { | |||
| 175 | + | bits |= stateFocused | |||
| 176 | + | } | |||
| 177 | + | r.sent.fullscreen, r.sent.activated = p.fullscreen, p.activated | |||
| 178 | + | r.fullscreen = p.fullscreen // ours now: the property is not read back | |||
| 179 | + | if bits != r.stateSent { | |||
| 180 | + | r.stateSent = bits | |||
| 181 | + | x.writeState(r) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // writeState rewrites _NET_WM_STATE of r from its bits. | |||
| 186 | + | func (x *XWM) writeState(r *record) { | |||
| 187 | + | var list [3]uint32 | |||
| 188 | + | n := 0 | |||
| 189 | + | if r.modal { | |||
| 190 | + | list[n] = x.atoms[atomNetWMStateModal] | |||
| 191 | + | n++ | |||
| 192 | + | } | |||
| 193 | + | if r.stateSent&stateFullscreen != 0 { | |||
| 194 | + | list[n] = x.atoms[atomNetWMStateFullscreen] | |||
| 195 | + | n++ | |||
| 196 | + | } | |||
| 197 | + | if r.stateSent&stateFocused != 0 { | |||
| 198 | + | list[n] = x.atoms[atomNetWMStateFocused] | |||
| 199 | + | n++ | |||
| 200 | + | } | |||
| 201 | + | x.setProp32(r.xid, atomNetWMState, xproto.AtomAtom, list[:n]) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // sendConfigureNotify tells the client its geometry as the XWM sees it. | |||
| 205 | + | func (x *XWM) sendConfigureNotify(r *record) { | |||
| 206 | + | var raw [32]byte | |||
| 207 | + | d := xproto.ConfigureNotifyEventData{ | |||
| 208 | + | Event: xproto.Window(r.xid), Window: xproto.Window(r.xid), | |||
| 209 | + | X: int16(min(max(r.x, math.MinInt16), math.MaxInt16)), Y: int16(min(max(r.y, math.MinInt16), math.MaxInt16)), | |||
| 210 | + | Width: uint16(min(r.w, math.MaxUint16)), Height: uint16(min(r.h, math.MaxUint16)), | |||
| 211 | + | } | |||
| 212 | + | d.Put(&raw) | |||
| 213 | + | xproto.SendEvent(x.conn.Buf(), false, xproto.Window(r.xid), xproto.EventMaskStructureNotify, &raw) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // onConfigureRequest answers a client resizing itself. Tiled windows are told | |||
| 217 | + | // their current geometry; floating ones get the size they ask for; unmanaged | |||
| 218 | + | // and unknown windows are configured as requested. | |||
| 219 | + | func (x *XWM) onConfigureRequest(ev *xturbo.Event) { | |||
| 220 | + | e := xproto.ConfigureRequestOf(ev) | |||
| 221 | + | xid := uint32(e.Window()) | |||
| 222 | + | mask := e.ValueMask() | |||
| 223 | + | s := x.tab.lookup(xid) | |||
| 224 | + | r := x.managedRec(s) | |||
| 225 | + | if r == nil { | |||
| 226 | + | var v xproto.ConfigureWindowValues | |||
| 227 | + | if mask&xproto.ConfigWindowX != 0 { | |||
| 228 | + | v.SetX(int32(e.X())) | |||
| 229 | + | } | |||
| 230 | + | if mask&xproto.ConfigWindowY != 0 { | |||
| 231 | + | v.SetY(int32(e.Y())) | |||
| 232 | + | } | |||
| 233 | + | if mask&xproto.ConfigWindowWidth != 0 { | |||
| 234 | + | v.SetWidth(uint32(e.Width())) | |||
| 235 | + | } | |||
| 236 | + | if mask&xproto.ConfigWindowHeight != 0 { | |||
| 237 | + | v.SetHeight(uint32(e.Height())) | |||
| 238 | + | } | |||
| 239 | + | if mask&xproto.ConfigWindowBorderWidth != 0 { | |||
| 240 | + | v.SetBorderWidth(uint32(e.BorderWidth())) | |||
| 241 | + | } | |||
| 242 | + | if mask&xproto.ConfigWindowSibling != 0 { | |||
| 243 | + | v.SetSibling(e.Sibling()) | |||
| 244 | + | } | |||
| 245 | + | if mask&xproto.ConfigWindowStackMode != 0 { | |||
| 246 | + | v.SetStackMode(e.StackMode()) | |||
| 247 | + | } | |||
| 248 | + | xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v) | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | if !r.ready { | |||
| 252 | + | x.configureBeforeReady(s, r, e) | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 255 | + | if r.floating && mask&(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 { | |||
| 256 | + | w, h := r.w, r.h | |||
| 257 | + | if mask&xproto.ConfigWindowWidth != 0 { | |||
| 258 | + | w = uint32(min(max(int(e.Width()), 1), maxClientSize)) | |||
| 259 | + | } | |||
| 260 | + | if mask&xproto.ConfigWindowHeight != 0 { | |||
| 261 | + | h = uint32(min(max(int(e.Height()), 1), maxClientSize)) | |||
| 262 | + | } | |||
| 263 | + | if w > 0 && h > 0 { | |||
| 264 | + | var v xproto.ConfigureWindowValues | |||
| 265 | + | v.SetWidth(w).SetHeight(h) | |||
| 266 | + | x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(xid), &v), kindConfigure, x.tab.tag(s), 0) | |||
| 267 | + | r.w, r.h = w, h | |||
| 268 | + | // The size is the client's own: core learns it from the commit | |||
| 269 | + | // and Configure only resends it when core wants another. | |||
| 270 | + | r.pend.w, r.pend.h = w, h | |||
| 271 | + | r.sent.w, r.sent.h = w, h | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | x.sendConfigureNotify(r) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // configureBeforeReady honours the geometry a window asks for before it is | |||
| 278 | + | // ready (XMoveResize before XMapWindow), floating or not: core only sees the | |||
| 279 | + | // window at Ready and the client must find the geometry it set. | |||
| 280 | + | func (x *XWM) configureBeforeReady(s Slot, r *record, e xproto.ConfigureRequestEvent) { | |||
| 281 | + | mask := e.ValueMask() | |||
| 282 | + | var v xproto.ConfigureWindowValues | |||
| 283 | + | if mask&xproto.ConfigWindowX != 0 { | |||
| 284 | + | r.x = int32(e.X()) | |||
| 285 | + | v.SetX(r.x) | |||
| 286 | + | } | |||
| 287 | + | if mask&xproto.ConfigWindowY != 0 { | |||
| 288 | + | r.y = int32(e.Y()) | |||
| 289 | + | v.SetY(r.y) | |||
| 290 | + | } | |||
| 291 | + | if mask&xproto.ConfigWindowWidth != 0 { | |||
| 292 | + | r.w, r.sized = uint32(min(max(int(e.Width()), 1), maxClientSize)), true | |||
| 293 | + | v.SetWidth(r.w) | |||
| 294 | + | } | |||
| 295 | + | if mask&xproto.ConfigWindowHeight != 0 { | |||
| 296 | + | r.h, r.sized = uint32(min(max(int(e.Height()), 1), maxClientSize)), true | |||
| 297 | + | v.SetHeight(r.h) | |||
| 298 | + | } | |||
| 299 | + | if mask&(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth|xproto.ConfigWindowHeight) != 0 { | |||
| 300 | + | x.expect(xproto.ConfigureWindow(x.conn.Buf(), xproto.Window(r.xid), &v), kindConfigure, x.tab.tag(s), 0) | |||
| 301 | + | } | |||
| 302 | + | x.sendConfigureNotify(r) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // onConfigureNotify keeps the known geometry of every top-level window. | |||
| 306 | + | func (x *XWM) onConfigureNotify(ev *xturbo.Event) { | |||
| 307 | + | e := xproto.ConfigureNotifyOf(ev) | |||
| 308 | + | if e.Event() != xproto.Window(x.root) { | |||
| 309 | + | return | |||
| 310 | + | } | |||
| 311 | + | if r := x.tab.rec(x.tab.lookup(uint32(e.Window()))); r != nil && r.kind != kindFree { | |||
| 312 | + | r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height()) | |||
| 313 | + | if r.kind == kindUnmanaged && r.ovPaired { | |||
| 314 | + | x.queueOverride(x.tab.lookup(r.xid), r) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | } | |||
A internal/adapters/wayland/xwm/configure_test.go +591 -0
| @@ -0,0 +1,591 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "runtime" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 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 | + | // newWin makes a ready managed window known to the XWM and the mocked host. | |||
| 16 | + | func newWin(t *testing.T, x *XWM, p *xpeer, h *mockhost, xid uint32) Slot { | |||
| 17 | + | t.Helper() | |||
| 18 | + | h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(xid)).Once() | |||
| 19 | + | p.createNotify(xid, false) | |||
| 20 | + | readUntil(t, x, func() bool { return x.tab.lookup(xid) != 0 }) | |||
| 21 | + | s := x.tab.lookup(xid) | |||
| 22 | + | r := x.tab.rec(s) | |||
| 23 | + | r.ready, r.w, r.h = true, 300, 200 | |||
| 24 | + | return s | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // settle flushes the XWM and waits until the peer has seen everything sent so | |||
| 28 | + | // far; it returns the number of requests received. | |||
| 29 | + | func settle(t *testing.T, x *XWM, p *xpeer) int { | |||
| 30 | + | t.Helper() | |||
| 31 | + | x.flush() | |||
| 32 | + | n := len(p.requests()) | |||
| 33 | + | xproto.GetInputFocus(x.conn.Buf()) | |||
| 34 | + | require.NoError(t, x.conn.Flush()) | |||
| 35 | + | _, n = p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus }) | |||
| 36 | + | return n | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // since returns the requests of the given major opcode received after skip. | |||
| 40 | + | func since(p *xpeer, skip int, major uint8) []xpeerReq { | |||
| 41 | + | var out []xpeerReq | |||
| 42 | + | for _, r := range p.requests()[skip:] { | |||
| 43 | + | if r.major == major { | |||
| 44 | + | out = append(out, r) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | return out | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // sentEvents returns the 32-byte events of the SendEvent requests to dest. | |||
| 51 | + | func sentEvents(p *xpeer, skip int, dest uint32) [][]byte { | |||
| 52 | + | var out [][]byte | |||
| 53 | + | for _, r := range since(p, skip, opSendEvent) { | |||
| 54 | + | if r.u32(4) == dest { | |||
| 55 | + | out = append(out, r.raw[12:44]) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return out | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func propsTo(p *xpeer, skip int, win uint32, atom string) []xpeerReq { | |||
| 62 | + | var out []xpeerReq | |||
| 63 | + | a := p.atom(atom) | |||
| 64 | + | for _, r := range since(p, skip, opChangeProperty) { | |||
| 65 | + | if r.u32(4) == win && r.u32(8) == a { | |||
| 66 | + | out = append(out, r) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return out | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (x *XWM) feed(t *testing.T, p *xpeer, send func()) { | |||
| 73 | + | t.Helper() | |||
| 74 | + | ev := x.stats.Events | |||
| 75 | + | send() | |||
| 76 | + | readUntil(t, x, func() bool { return x.stats.Events > ev }) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestConfigureCoalescesIntoOneRequest(t *testing.T) { | |||
| 80 | + | x, p, h, _, runs := attached(t) | |||
| 81 | + | const win = 0x500001 | |||
| 82 | + | s := newWin(t, x, p, h, win) | |||
| 83 | + | n := settle(t, x, p) | |||
| 84 | + | ||||
| 85 | + | x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480}) | |||
| 86 | + | x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true}) | |||
| 87 | + | x.Place(s, 10, 20) | |||
| 88 | + | require.Len(t, *runs, 1, "one flush posted") | |||
| 89 | + | n2 := settle(t, x, p) | |||
| 90 | + | ||||
| 91 | + | cw := since(p, n, opConfigureWindow) | |||
| 92 | + | require.Len(t, cw, 1) | |||
| 93 | + | require.Equal(t, uint32(win), cw[0].u32(4)) | |||
| 94 | + | require.EqualValues(t, 0x1f, binary.LittleEndian.Uint16(cw[0].raw[8:]), "x y width height border") | |||
| 95 | + | 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)}) | |||
| 96 | + | ||||
| 97 | + | evs := sentEvents(p, n, win) | |||
| 98 | + | require.Len(t, evs, 1, "one synthetic ConfigureNotify") | |||
| 99 | + | require.EqualValues(t, 22, evs[0][0]) | |||
| 100 | + | require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][4:])) | |||
| 101 | + | require.EqualValues(t, win, binary.LittleEndian.Uint32(evs[0][8:])) | |||
| 102 | + | require.EqualValues(t, 10, binary.LittleEndian.Uint16(evs[0][16:])) | |||
| 103 | + | require.EqualValues(t, 20, binary.LittleEndian.Uint16(evs[0][18:])) | |||
| 104 | + | require.EqualValues(t, 800, binary.LittleEndian.Uint16(evs[0][20:])) | |||
| 105 | + | require.EqualValues(t, 600, binary.LittleEndian.Uint16(evs[0][22:])) | |||
| 106 | + | require.Equal(t, xproto.EventMaskStructureNotify, xproto.EventMask(since(p, n, opSendEvent)[0].u32(8))) | |||
| 107 | + | ||||
| 108 | + | // Unchanged: nothing is sent. | |||
| 109 | + | x.Configure(s, ports.ConfigureWindow{Width: 800, Height: 600, Activated: true}) | |||
| 110 | + | x.Place(s, 10, 20) | |||
| 111 | + | settle(t, x, p) | |||
| 112 | + | require.Len(t, since(p, n2, opConfigureWindow), 0) | |||
| 113 | + | require.Len(t, sentEvents(p, n2, win), 0) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestConfigureWritesNetWMState(t *testing.T) { | |||
| 117 | + | x, p, h, _, _ := attached(t) | |||
| 118 | + | const win = 0x500001 | |||
| 119 | + | s := newWin(t, x, p, h, win) | |||
| 120 | + | x.tab.rec(s).modal = true | |||
| 121 | + | n := settle(t, x, p) | |||
| 122 | + | ||||
| 123 | + | x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100}) | |||
| 124 | + | settle(t, x, p) | |||
| 125 | + | require.Empty(t, propsTo(p, n, win, "_NET_WM_STATE"), "no state bit changed") | |||
| 126 | + | ||||
| 127 | + | x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Fullscreen: true, Activated: true}) | |||
| 128 | + | settle(t, x, p) | |||
| 129 | + | st, ok := p.prop(win, "_NET_WM_STATE") | |||
| 130 | + | require.True(t, ok) | |||
| 131 | + | require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FULLSCREEN"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st)) | |||
| 132 | + | ||||
| 133 | + | x.Configure(s, ports.ConfigureWindow{Width: 100, Height: 100, Activated: true}) | |||
| 134 | + | settle(t, x, p) | |||
| 135 | + | st, _ = p.prop(win, "_NET_WM_STATE") | |||
| 136 | + | require.ElementsMatch(t, []uint32{p.atom("_NET_WM_STATE_MODAL"), p.atom("_NET_WM_STATE_FOCUSED")}, atomsOf(st)) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestConfigureBadWindowIsAttributedAndIgnored(t *testing.T) { | |||
| 140 | + | x, p, h, _, _ := attached(t) | |||
| 141 | + | const win = 0x500001 | |||
| 142 | + | s := newWin(t, x, p, h, win) | |||
| 143 | + | n := settle(t, x, p) | |||
| 144 | + | x.Configure(s, ports.ConfigureWindow{Width: 640, Height: 480}) | |||
| 145 | + | x.flush() | |||
| 146 | + | cw := []xpeerReq{} | |||
| 147 | + | r, _ := p.waitReq(n, func(r xpeerReq) bool { return r.major == opConfigureWindow }) | |||
| 148 | + | cw = append(cw, r) | |||
| 149 | + | ||||
| 150 | + | p.mu.Lock() | |||
| 151 | + | e := make([]byte, 32) | |||
| 152 | + | e[1] = xproto.BadWindow | |||
| 153 | + | binary.LittleEndian.PutUint16(e[2:], cw[0].seq) | |||
| 154 | + | binary.LittleEndian.PutUint32(e[4:], win) | |||
| 155 | + | e[10] = opConfigureWindow | |||
| 156 | + | p.write(e) | |||
| 157 | + | p.mu.Unlock() | |||
| 158 | + | readUntil(t, x, func() bool { return x.stats.Errors == 1 }) | |||
| 159 | + | require.Zero(t, x.stats.Unattributed) | |||
| 160 | + | require.False(t, x.closed) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func sendConfigureRequest(p *xpeer, win uint32, mask uint16, x, y int16, w, h uint16) { | |||
| 164 | + | p.event(23, func(b *[32]byte) { | |||
| 165 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 166 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 167 | + | binary.LittleEndian.PutUint16(b[16:], uint16(x)) | |||
| 168 | + | binary.LittleEndian.PutUint16(b[18:], uint16(y)) | |||
| 169 | + | binary.LittleEndian.PutUint16(b[20:], w) | |||
| 170 | + | binary.LittleEndian.PutUint16(b[22:], h) | |||
| 171 | + | binary.LittleEndian.PutUint16(b[26:], mask) | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestConfigureRequestTiledOnlyGetsNotify(t *testing.T) { | |||
| 176 | + | x, p, h, _, _ := attached(t) | |||
| 177 | + | const win = 0x500001 | |||
| 178 | + | newWin(t, x, p, h, win) | |||
| 179 | + | n := settle(t, x, p) | |||
| 180 | + | ||||
| 181 | + | x.feed(t, p, func() { sendConfigureRequest(p, win, 0xf, 5, 6, 1000, 900) }) | |||
| 182 | + | settle(t, x, p) | |||
| 183 | + | require.Empty(t, since(p, n, opConfigureWindow), "a tiled window cannot resize itself") | |||
| 184 | + | evs := sentEvents(p, n, win) | |||
| 185 | + | require.Len(t, evs, 1) | |||
| 186 | + | require.EqualValues(t, 22, evs[0][0]) | |||
| 187 | + | require.EqualValues(t, 300, binary.LittleEndian.Uint16(evs[0][20:]), "current geometry") | |||
| 188 | + | require.EqualValues(t, 200, binary.LittleEndian.Uint16(evs[0][22:])) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestConfigureRequestFloatingHonoursSize(t *testing.T) { | |||
| 192 | + | x, p, h, _, _ := attached(t) | |||
| 193 | + | const win = 0x500001 | |||
| 194 | + | s := newWin(t, x, p, h, win) | |||
| 195 | + | x.tab.rec(s).floating = true | |||
| 196 | + | n := settle(t, x, p) | |||
| 197 | + | ||||
| 198 | + | x.feed(t, p, func() { | |||
| 199 | + | sendConfigureRequest(p, win, uint16(xproto.ConfigWindowWidth|xproto.ConfigWindowHeight), 0, 0, 70000&0xffff, 20000) | |||
| 200 | + | }) | |||
| 201 | + | settle(t, x, p) | |||
| 202 | + | cw := since(p, n, opConfigureWindow) | |||
| 203 | + | require.Len(t, cw, 1) | |||
| 204 | + | require.EqualValues(t, xproto.ConfigWindowWidth|xproto.ConfigWindowHeight, binary.LittleEndian.Uint16(cw[0].raw[8:])) | |||
| 205 | + | require.EqualValues(t, 4464, cw[0].u32(12)) | |||
| 206 | + | require.EqualValues(t, maxClientSize, cw[0].u32(16), "clamped") | |||
| 207 | + | require.Len(t, sentEvents(p, n, win), 1) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func TestConfigureRequestUnmanagedHonouredVerbatim(t *testing.T) { | |||
| 211 | + | x, p, _, _, _ := attached(t) | |||
| 212 | + | const win = 0x500009 | |||
| 213 | + | p.createNotify(win, true) | |||
| 214 | + | readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 }) | |||
| 215 | + | n := settle(t, x, p) | |||
| 216 | + | ||||
| 217 | + | x.feed(t, p, func() { | |||
| 218 | + | sendConfigureRequest(p, win, uint16(xproto.ConfigWindowX|xproto.ConfigWindowY|xproto.ConfigWindowWidth), 30, 40, 50, 60) | |||
| 219 | + | }) | |||
| 220 | + | settle(t, x, p) | |||
| 221 | + | cw := since(p, n, opConfigureWindow) | |||
| 222 | + | require.Len(t, cw, 1) | |||
| 223 | + | require.EqualValues(t, 7, binary.LittleEndian.Uint16(cw[0].raw[8:])) | |||
| 224 | + | require.Equal(t, []uint32{30, 40, 50}, []uint32{cw[0].u32(12), cw[0].u32(16), cw[0].u32(20)}) | |||
| 225 | + | require.Empty(t, sentEvents(p, n, win)) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestConfigureStaleAndDuplicateDirtySlots(t *testing.T) { | |||
| 229 | + | x, p, h, _, _ := attached(t) | |||
| 230 | + | const a, b = 0x500001, 0x500002 | |||
| 231 | + | s := newWin(t, x, p, h, a) | |||
| 232 | + | for i := 1; i <= 50; i++ { | |||
| 233 | + | x.Configure(s, ports.ConfigureWindow{Width: 100 + i, Height: 100}) | |||
| 234 | + | } | |||
| 235 | + | require.Len(t, x.dirty, 1, "queued once per false→true transition") | |||
| 236 | + | ||||
| 237 | + | // The window dies with its entry queued; the slot is reused. | |||
| 238 | + | h.EXPECT().Dissociate(s, true).Once() | |||
| 239 | + | p.destroyNotify(a) | |||
| 240 | + | readUntil(t, x, func() bool { return x.tab.lookup(a) == 0 }) | |||
| 241 | + | s2 := newWin(t, x, p, h, b) | |||
| 242 | + | require.Equal(t, s, s2, "LIFO slot reuse") | |||
| 243 | + | n := len(p.requests()) | |||
| 244 | + | x.Configure(s2, ports.ConfigureWindow{Width: 640, Height: 480}) | |||
| 245 | + | require.Len(t, x.dirty, 1) | |||
| 246 | + | x.dirty = append(x.dirty, s, s) // stale and duplicate entries must be skipped | |||
| 247 | + | settle(t, x, p) | |||
| 248 | + | cw := since(p, n, opConfigureWindow) | |||
| 249 | + | require.Len(t, cw, 1, "sent once despite duplicates") | |||
| 250 | + | require.Equal(t, uint32(b), cw[0].u32(4)) | |||
| 251 | + | require.Empty(t, x.dirty) | |||
| 252 | + | require.LessOrEqual(t, cap(x.dirty), len(x.tab.recs)) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func TestDirtyQueueNeverGrows(t *testing.T) { | |||
| 256 | + | x, p, h, _, _ := attached(t) | |||
| 257 | + | c0 := cap(x.dirty) | |||
| 258 | + | const base = 0x600000 | |||
| 259 | + | for i := 0; i < 3*c0; i++ { // each round leaves a stale entry behind | |||
| 260 | + | xid := uint32(base + i) | |||
| 261 | + | h.EXPECT().Created(mock.Anything, xid).Return(ports.WindowID(1)).Once() | |||
| 262 | + | p.createNotify(xid, false) | |||
| 263 | + | readUntil(t, x, func() bool { return x.tab.lookup(xid) != 0 }) | |||
| 264 | + | s := x.tab.lookup(xid) | |||
| 265 | + | x.tab.rec(s).ready = true | |||
| 266 | + | x.Configure(s, ports.ConfigureWindow{Width: 10, Height: 10}) | |||
| 267 | + | h.EXPECT().Dissociate(s, true).Once() | |||
| 268 | + | p.destroyNotify(xid) | |||
| 269 | + | readUntil(t, x, func() bool { return x.tab.lookup(xid) == 0 }) | |||
| 270 | + | } | |||
| 271 | + | require.Equal(t, c0, cap(x.dirty)) | |||
| 272 | + | require.LessOrEqual(t, len(x.dirty), c0) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestPublicCommandsIgnoredWhenClosed(t *testing.T) { | |||
| 276 | + | x, p, h, src, _ := attached(t) | |||
| 277 | + | const win = 0x500001 | |||
| 278 | + | s := newWin(t, x, p, h, win) | |||
| 279 | + | h.EXPECT().Dissociate(s, true).Once() | |||
| 280 | + | src.EXPECT().Remove().Once() | |||
| 281 | + | x.Close() | |||
| 282 | + | require.NotPanics(t, func() { | |||
| 283 | + | x.Configure(s, ports.ConfigureWindow{Width: 1, Height: 1}) | |||
| 284 | + | x.Place(s, 1, 2) | |||
| 285 | + | x.Focus(s) | |||
| 286 | + | x.CloseWindow(s) | |||
| 287 | + | }) | |||
| 288 | + | require.Empty(t, x.dirty) | |||
| 289 | + | require.Zero(t, x.active) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // Focus. | |||
| 293 | + | ||||
| 294 | + | type focusModel struct { | |||
| 295 | + | name string | |||
| 296 | + | input, take bool | |||
| 297 | + | setFocus bool // SetInputFocus on the window | |||
| 298 | + | takeFocus bool // WM_TAKE_FOCUS sent | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func TestFocusInputModels(t *testing.T) { | |||
| 302 | + | for _, m := range []focusModel{ | |||
| 303 | + | {"passive", true, false, true, false}, | |||
| 304 | + | {"locally active", true, true, true, true}, | |||
| 305 | + | {"globally active", false, true, false, true}, | |||
| 306 | + | {"no input", false, false, false, false}, | |||
| 307 | + | } { | |||
| 308 | + | t.Run(m.name, func(t *testing.T) { | |||
| 309 | + | x, p, h, _, _ := attached(t) | |||
| 310 | + | const win = 0x500001 | |||
| 311 | + | s := newWin(t, x, p, h, win) | |||
| 312 | + | r := x.tab.rec(s) | |||
| 313 | + | r.input, r.takeFocus = m.input, m.take | |||
| 314 | + | n := settle(t, x, p) | |||
| 315 | + | ||||
| 316 | + | x.Focus(s) | |||
| 317 | + | settle(t, x, p) | |||
| 318 | + | var focuses []xpeerReq | |||
| 319 | + | for _, r := range since(p, n, opSetInputFocus) { | |||
| 320 | + | if r.u32(4) == win { | |||
| 321 | + | focuses = append(focuses, r) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | if m.setFocus { | |||
| 325 | + | require.Len(t, focuses, 1) | |||
| 326 | + | require.EqualValues(t, xproto.InputFocusNone, focuses[0].raw[1], "never PointerRoot") | |||
| 327 | + | require.Zero(t, focuses[0].u32(8), "CurrentTime") | |||
| 328 | + | } else { | |||
| 329 | + | require.Empty(t, focuses) | |||
| 330 | + | } | |||
| 331 | + | var takes int | |||
| 332 | + | for _, ev := range sentEvents(p, n, win) { | |||
| 333 | + | if ev[0] == 33 && binary.LittleEndian.Uint32(ev[8:]) == p.atom("WM_PROTOCOLS") { | |||
| 334 | + | require.EqualValues(t, 32, ev[1]) | |||
| 335 | + | require.Equal(t, p.atom("WM_TAKE_FOCUS"), binary.LittleEndian.Uint32(ev[12:])) | |||
| 336 | + | require.Zero(t, binary.LittleEndian.Uint32(ev[16:]), "CurrentTime") | |||
| 337 | + | takes++ | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | require.Equal(t, map[bool]int{true: 1, false: 0}[m.takeFocus], takes) | |||
| 341 | + | ||||
| 342 | + | act, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW") | |||
| 343 | + | require.True(t, ok) | |||
| 344 | + | require.Equal(t, []uint32{win}, atomsOf(act), "named even for No-input windows") | |||
| 345 | + | st, ok := p.prop(win, "_NET_WM_STATE") | |||
| 346 | + | require.True(t, ok) | |||
| 347 | + | require.Contains(t, atomsOf(st), p.atom("_NET_WM_STATE_FOCUSED")) | |||
| 348 | + | }) | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func TestFocusActiveWindowWrittenOnlyOnChange(t *testing.T) { | |||
| 353 | + | x, p, h, _, _ := attached(t) | |||
| 354 | + | const a, b = 0x500001, 0x500002 | |||
| 355 | + | sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b) | |||
| 356 | + | n := settle(t, x, p) | |||
| 357 | + | ||||
| 358 | + | x.Focus(sa) | |||
| 359 | + | x.Focus(sa) | |||
| 360 | + | settle(t, x, p) | |||
| 361 | + | require.Len(t, propsTo(p, n, peerRoot, "_NET_ACTIVE_WINDOW"), 1) | |||
| 362 | + | ||||
| 363 | + | x.Focus(sb) | |||
| 364 | + | settle(t, x, p) | |||
| 365 | + | act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW") | |||
| 366 | + | require.Equal(t, []uint32{b}, atomsOf(act)) | |||
| 367 | + | sta, _ := p.prop(a, "_NET_WM_STATE") | |||
| 368 | + | require.NotContains(t, atomsOf(sta), p.atom("_NET_WM_STATE_FOCUSED"), "old window loses FOCUSED") | |||
| 369 | + | stb, _ := p.prop(b, "_NET_WM_STATE") | |||
| 370 | + | require.Contains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED")) | |||
| 371 | + | ||||
| 372 | + | n = settle(t, x, p) | |||
| 373 | + | x.Focus(0) | |||
| 374 | + | settle(t, x, p) | |||
| 375 | + | none := since(p, n, opSetInputFocus) | |||
| 376 | + | require.Len(t, none, 1) | |||
| 377 | + | require.Zero(t, none[0].u32(4), "focus None") | |||
| 378 | + | require.EqualValues(t, xproto.InputFocusNone, none[0].raw[1]) | |||
| 379 | + | act, _ = p.prop(peerRoot, "_NET_ACTIVE_WINDOW") | |||
| 380 | + | require.Equal(t, []uint32{0}, atomsOf(act)) | |||
| 381 | + | stb, _ = p.prop(b, "_NET_WM_STATE") | |||
| 382 | + | require.NotContains(t, atomsOf(stb), p.atom("_NET_WM_STATE_FOCUSED")) | |||
| 383 | + | require.Zero(t, x.active) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | func TestFocusStackingLists(t *testing.T) { | |||
| 387 | + | x, p, h, _, _ := attached(t) | |||
| 388 | + | const a, b, c = 0x500001, 0x500002, 0x500003 | |||
| 389 | + | sa := newWin(t, x, p, h, a) | |||
| 390 | + | newWin(t, x, p, h, b) | |||
| 391 | + | newWin(t, x, p, h, c) | |||
| 392 | + | for _, w := range []uint32{a, b, c} { | |||
| 393 | + | x.addClient(w) | |||
| 394 | + | } | |||
| 395 | + | settle(t, x, p) | |||
| 396 | + | list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST") | |||
| 397 | + | require.Equal(t, []uint32{a, b, c}, atomsOf(list)) | |||
| 398 | + | n := settle(t, x, p) | |||
| 399 | + | ||||
| 400 | + | x.Focus(sa) | |||
| 401 | + | settle(t, x, p) | |||
| 402 | + | stack, _ := p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING") | |||
| 403 | + | require.Equal(t, []uint32{b, c, a}, atomsOf(stack), "bottom to top") | |||
| 404 | + | list, _ = p.prop(peerRoot, "_NET_CLIENT_LIST") | |||
| 405 | + | require.Equal(t, []uint32{a, b, c}, atomsOf(list), "map order") | |||
| 406 | + | require.Empty(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), "clients list untouched by focus") | |||
| 407 | + | var raised bool | |||
| 408 | + | for _, r := range since(p, n, opConfigureWindow) { | |||
| 409 | + | if r.u32(4) == a && binary.LittleEndian.Uint16(r.raw[8:]) == uint16(xproto.ConfigWindowStackMode) { | |||
| 410 | + | require.EqualValues(t, xproto.StackModeAbove, r.u32(12)) | |||
| 411 | + | raised = true | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | require.True(t, raised) | |||
| 415 | + | ||||
| 416 | + | // A burst costs one write per list. | |||
| 417 | + | n = settle(t, x, p) | |||
| 418 | + | x.removeClient(b) | |||
| 419 | + | x.removeClient(c) | |||
| 420 | + | settle(t, x, p) | |||
| 421 | + | require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST"), 1) | |||
| 422 | + | require.Len(t, propsTo(p, n, peerRoot, "_NET_CLIENT_LIST_STACKING"), 1) | |||
| 423 | + | stack, _ = p.prop(peerRoot, "_NET_CLIENT_LIST_STACKING") | |||
| 424 | + | require.Equal(t, []uint32{a}, atomsOf(stack)) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func TestDestroyedActiveWindowClearsActive(t *testing.T) { | |||
| 428 | + | x, p, h, _, _ := attached(t) | |||
| 429 | + | const win = 0x500001 | |||
| 430 | + | s := newWin(t, x, p, h, win) | |||
| 431 | + | x.addClient(win) | |||
| 432 | + | x.Focus(s) | |||
| 433 | + | settle(t, x, p) | |||
| 434 | + | h.EXPECT().Dissociate(s, true).Once() | |||
| 435 | + | p.destroyNotify(win) | |||
| 436 | + | readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 }) | |||
| 437 | + | settle(t, x, p) | |||
| 438 | + | require.Zero(t, x.active) | |||
| 439 | + | act, _ := p.prop(peerRoot, "_NET_ACTIVE_WINDOW") | |||
| 440 | + | require.Equal(t, []uint32{0}, atomsOf(act)) | |||
| 441 | + | list, _ := p.prop(peerRoot, "_NET_CLIENT_LIST") | |||
| 442 | + | require.Empty(t, list.data) | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestFocusInDenied(t *testing.T) { | |||
| 446 | + | x, p, h, _, _ := attached(t) | |||
| 447 | + | const a, b = 0x500001, 0x500002 | |||
| 448 | + | sa := newWin(t, x, p, h, a) | |||
| 449 | + | newWin(t, x, p, h, b) | |||
| 450 | + | x.Focus(sa) | |||
| 451 | + | n := settle(t, x, p) | |||
| 452 | + | ||||
| 453 | + | focusIn := func(win uint32, detail byte) { | |||
| 454 | + | p.event(9, func(e *[32]byte) { | |||
| 455 | + | e[1] = detail | |||
| 456 | + | binary.LittleEndian.PutUint32(e[4:], win) | |||
| 457 | + | }) | |||
| 458 | + | } | |||
| 459 | + | focusCount := func(skip int) int { | |||
| 460 | + | c := 0 | |||
| 461 | + | for _, r := range since(p, skip, opSetInputFocus) { | |||
| 462 | + | if r.u32(4) == a { | |||
| 463 | + | c++ | |||
| 464 | + | } | |||
| 465 | + | } | |||
| 466 | + | return c | |||
| 467 | + | } | |||
| 468 | + | x.feed(t, p, func() { focusIn(a, 0) }) // the active window itself: fine | |||
| 469 | + | x.feed(t, p, func() { focusIn(b, byte(xproto.NotifyDetailPointer)) }) | |||
| 470 | + | settle(t, x, p) | |||
| 471 | + | require.Zero(t, focusCount(n)) | |||
| 472 | + | ||||
| 473 | + | x.feed(t, p, func() { focusIn(b, 0); focusIn(b, 0) }) | |||
| 474 | + | readUntil(t, x, func() bool { return x.stats.Events >= 5 }) | |||
| 475 | + | settle(t, x, p) | |||
| 476 | + | require.Equal(t, 1, focusCount(n), "denied once per iteration") | |||
| 477 | + | n = settle(t, x, p) | |||
| 478 | + | settle(t, x, p) | |||
| 479 | + | require.Zero(t, focusCount(n)) | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func TestCloseWindow(t *testing.T) { | |||
| 483 | + | x, p, h, _, _ := attached(t) | |||
| 484 | + | const a, b = 0x500001, 0x500002 | |||
| 485 | + | sa, sb := newWin(t, x, p, h, a), newWin(t, x, p, h, b) | |||
| 486 | + | x.tab.rec(sa).deleteWindow = true | |||
| 487 | + | n := settle(t, x, p) | |||
| 488 | + | ||||
| 489 | + | x.CloseWindow(sa) | |||
| 490 | + | x.CloseWindow(sb) | |||
| 491 | + | settle(t, x, p) | |||
| 492 | + | evs := sentEvents(p, n, a) | |||
| 493 | + | require.Len(t, evs, 1) | |||
| 494 | + | require.EqualValues(t, 33, evs[0][0]) | |||
| 495 | + | require.EqualValues(t, 32, evs[0][1]) | |||
| 496 | + | require.Equal(t, p.atom("WM_PROTOCOLS"), binary.LittleEndian.Uint32(evs[0][8:])) | |||
| 497 | + | require.Equal(t, p.atom("WM_DELETE_WINDOW"), binary.LittleEndian.Uint32(evs[0][12:])) | |||
| 498 | + | require.Zero(t, binary.LittleEndian.Uint32(evs[0][16:])) | |||
| 499 | + | require.Empty(t, sentEvents(p, n, b)) | |||
| 500 | + | ||||
| 501 | + | kills := since(p, n, opKillClient) | |||
| 502 | + | require.Len(t, kills, 1) | |||
| 503 | + | require.Equal(t, uint32(b), kills[0].u32(4)) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | // Allocation guards. The peer sinks requests so the connection never blocks; | |||
| 507 | + | // its goroutine is idle in the sink loop before measuring. | |||
| 508 | + | ||||
| 509 | + | func sinkPeer(t *testing.T, x *XWM, p *xpeer) { | |||
| 510 | + | t.Helper() | |||
| 511 | + | // Everything written after sink.Store(true) is discarded, so one marker | |||
| 512 | + | // request proves the peer loop is in sink mode and idle again. | |||
| 513 | + | // A round trip first: the peer has consumed every earlier request, so the | |||
| 514 | + | // marker is the only one it can see in sink mode. | |||
| 515 | + | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 516 | + | before := p.discarded.Load() | |||
| 517 | + | p.sink.Store(true) | |||
| 518 | + | xproto.GetInputFocus(x.conn.Buf()) | |||
| 519 | + | require.NoError(t, x.conn.Flush()) | |||
| 520 | + | deadline := time.Now().Add(30 * time.Second) // failure guard only: the wait is event driven | |||
| 521 | + | for p.discarded.Load() == before { | |||
| 522 | + | if time.Now().After(deadline) { | |||
| 523 | + | t.Fatal("xpeer: sink marker never discarded") | |||
| 524 | + | } | |||
| 525 | + | runtime.Gosched() | |||
| 526 | + | } | |||
| 527 | + | x.src = nil // no source: write backpressure would not allocate either | |||
| 528 | + | x.post = func(func()) {} | |||
| 529 | + | x.warnedRing = true // the sink never answers: Expect slots stay used | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func TestConfigureFlushAllocations(t *testing.T) { | |||
| 533 | + | x, p, h, _, _ := attached(t) | |||
| 534 | + | s := newWin(t, x, p, h, 0x500001) | |||
| 535 | + | sinkPeer(t, x, p) | |||
| 536 | + | i := 0 | |||
| 537 | + | allocs := testing.AllocsPerRun(300, func() { | |||
| 538 | + | i++ | |||
| 539 | + | x.Configure(s, ports.ConfigureWindow{Width: 100 + i%2, Height: 100, Fullscreen: i%3 == 0, Activated: i%2 == 0}) | |||
| 540 | + | x.Place(s, i%5, i%7) | |||
| 541 | + | x.posted = false | |||
| 542 | + | x.flush() | |||
| 543 | + | }) | |||
| 544 | + | require.Zero(t, allocs) | |||
| 545 | + | require.False(t, x.closed) | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func TestFocusAllocations(t *testing.T) { | |||
| 549 | + | x, p, h, _, _ := attached(t) | |||
| 550 | + | a, b := newWin(t, x, p, h, 0x500001), newWin(t, x, p, h, 0x500002) | |||
| 551 | + | x.addClient(x.tab.rec(a).xid) | |||
| 552 | + | x.addClient(x.tab.rec(b).xid) | |||
| 553 | + | x.tab.rec(b).takeFocus = true | |||
| 554 | + | x.tab.rec(a).input, x.tab.rec(b).input = true, true | |||
| 555 | + | sinkPeer(t, x, p) | |||
| 556 | + | i := 0 | |||
| 557 | + | allocs := testing.AllocsPerRun(150, func() { | |||
| 558 | + | i++ | |||
| 559 | + | switch i % 3 { | |||
| 560 | + | case 0: | |||
| 561 | + | x.Focus(a) | |||
| 562 | + | case 1: | |||
| 563 | + | x.Focus(b) | |||
| 564 | + | default: | |||
| 565 | + | x.Focus(0) | |||
| 566 | + | } | |||
| 567 | + | x.refocus = i%5 == 0 | |||
| 568 | + | x.posted = false | |||
| 569 | + | x.flush() | |||
| 570 | + | }) | |||
| 571 | + | require.Zero(t, allocs) | |||
| 572 | + | require.False(t, x.closed) | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // Repeated identical fullscreen configures cost nothing. | |||
| 576 | + | func TestFullscreenConfigureAllocations(t *testing.T) { | |||
| 577 | + | x, p, h, _, _ := attached(t) | |||
| 578 | + | s := newWin(t, x, p, h, 0x500001) | |||
| 579 | + | sinkPeer(t, x, p) | |||
| 580 | + | cfg := ports.ConfigureWindow{Width: 1920, Height: 1080, Fullscreen: true, Activated: true} | |||
| 581 | + | x.Configure(s, cfg) | |||
| 582 | + | x.posted = false | |||
| 583 | + | x.flush() | |||
| 584 | + | allocs := testing.AllocsPerRun(300, func() { | |||
| 585 | + | x.Configure(s, cfg) | |||
| 586 | + | x.Place(s, 0, 0) | |||
| 587 | + | x.posted = false | |||
| 588 | + | x.flush() | |||
| 589 | + | }) | |||
| 590 | + | require.Zero(t, allocs) | |||
| 591 | + | } | |||
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 +303 -0
| @@ -0,0 +1,303 @@ | |||||
| 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.KindPropertyNotify: | |||
| 40 | + | x.noteTime(uint32(xproto.PropertyNotifyOf(ev).Time())) | |||
| 41 | + | case xproto.KindSelectionRequest: | |||
| 42 | + | x.noteTime(uint32(xproto.SelectionRequestOf(ev).Time())) | |||
| 43 | + | } | |||
| 44 | + | switch ev.Kind { | |||
| 45 | + | case xproto.KindCreateNotify: | |||
| 46 | + | x.onCreateNotify(ev) | |||
| 47 | + | case xproto.KindDestroyNotify: | |||
| 48 | + | x.onDestroyNotify(ev) | |||
| 49 | + | case xproto.KindReparentNotify: | |||
| 50 | + | x.onReparentNotify(ev) | |||
| 51 | + | } | |||
| 52 | + | if ev.Kind == xproto.KindSelectionClear { | |||
| 53 | + | x.onXSettingsClear(ev) | |||
| 54 | + | } | |||
| 55 | + | if x.host == nil { | |||
| 56 | + | x.onEventDetached(ev) | |||
| 57 | + | return | |||
| 58 | + | } | |||
| 59 | + | switch ev.Kind { | |||
| 60 | + | case xproto.KindMapRequest: | |||
| 61 | + | x.onMapRequest(ev) | |||
| 62 | + | case xproto.KindMapNotify: | |||
| 63 | + | x.onMapNotify(ev) | |||
| 64 | + | case xproto.KindUnmapNotify: | |||
| 65 | + | x.onUnmapNotify(ev) | |||
| 66 | + | case xproto.KindConfigureRequest: | |||
| 67 | + | x.onConfigureRequest(ev) | |||
| 68 | + | case xproto.KindConfigureNotify: | |||
| 69 | + | x.onConfigureNotify(ev) | |||
| 70 | + | case xproto.KindPropertyNotify: | |||
| 71 | + | x.onPropertyNotify(ev) | |||
| 72 | + | case xproto.KindClientMessage: | |||
| 73 | + | x.onClientMessage(ev) | |||
| 74 | + | case xproto.KindFocusIn: | |||
| 75 | + | x.onFocusIn(ev) | |||
| 76 | + | default: | |||
| 77 | + | x.onSelectionEvent(ev) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // onEventDetached keeps what Attach replays: windows asked to map, and those | |||
| 82 | + | // withdrawn again, so Attach skips them; geometry requests are answered. | |||
| 83 | + | func (x *XWM) onEventDetached(ev *xturbo.Event) { | |||
| 84 | + | switch ev.Kind { | |||
| 85 | + | case xproto.KindMapRequest: | |||
| 86 | + | if s := x.tab.lookup(uint32(xproto.MapRequestOf(ev).Window())); s != 0 { | |||
| 87 | + | x.tab.rec(s).mapping = true | |||
| 88 | + | } | |||
| 89 | + | case xproto.KindUnmapNotify: | |||
| 90 | + | if ev.SendEvent { | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | if s := x.tab.lookup(uint32(xproto.UnmapNotifyOf(ev).Window())); s != 0 { | |||
| 94 | + | r := x.tab.rec(s) | |||
| 95 | + | r.mapped, r.mapping = false, false | |||
| 96 | + | } | |||
| 97 | + | case xproto.KindConfigureRequest: | |||
| 98 | + | x.onConfigureRequest(ev) | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (x *XWM) onReply(r *xturbo.Reply) { | |||
| 103 | + | x.stats.Replies++ | |||
| 104 | + | if x.closed { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | switch r.Kind { | |||
| 108 | + | case kindVersion: | |||
| 109 | + | x.onVersionReply(r) | |||
| 110 | + | return | |||
| 111 | + | case kindTree: | |||
| 112 | + | x.onTreeReply(r) | |||
| 113 | + | return | |||
| 114 | + | case kindSelTargets: | |||
| 115 | + | x.onTargetsReply(r) | |||
| 116 | + | return | |||
| 117 | + | case kindSelData: | |||
| 118 | + | x.onSelDataReply(r) | |||
| 119 | + | return | |||
| 120 | + | case kindSelOwner: | |||
| 121 | + | x.onSelOwnerReply(r) | |||
| 122 | + | return | |||
| 123 | + | case kindPictFormats: | |||
| 124 | + | x.onPictFormatsReply(r) | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | s, rec := x.tab.live(r.Args[0]) | |||
| 128 | + | if rec == nil { | |||
| 129 | + | x.stats.Stale++ | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | switch r.Kind { | |||
| 133 | + | case kindAttr: | |||
| 134 | + | x.onAttrReply(rec, r) | |||
| 135 | + | case kindProp: | |||
| 136 | + | x.onPropReply(s, rec, r) | |||
| 137 | + | case kindGeom: | |||
| 138 | + | x.onGeomReply(s, rec, r) | |||
| 139 | + | case kindClientIds: | |||
| 140 | + | x.onClientIdsReply(s, rec, r) | |||
| 141 | + | case kindOverrideType: | |||
| 142 | + | x.onOverrideType(s, rec, r) | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // onError never tears the XWM down: an error names a request, not the | |||
| 147 | + | // connection. Sticky Buf errors surface through Flush. | |||
| 148 | + | func (x *XWM) onError(e *xturbo.XError) { | |||
| 149 | + | x.stats.Errors++ | |||
| 150 | + | if e.Kind == 0 { | |||
| 151 | + | x.stats.Unattributed++ | |||
| 152 | + | x.log.Debug().Uint8("code", e.Code).Uint8("major", e.Major).Uint16("minor", e.Minor). | |||
| 153 | + | Uint64("seq", uint64(e.Seq)).Msg("unattributed X error") | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | switch e.Kind { | |||
| 157 | + | case kindSelTargets, kindSelData, kindSelOut: | |||
| 158 | + | x.onSelError(e) | |||
| 159 | + | case kindRootMask: | |||
| 160 | + | if e.Code == xproto.BadAccess { | |||
| 161 | + | x.bootErr = ErrWMExists | |||
| 162 | + | } | |||
| 163 | + | case kindProp, kindGeom, kindClientIds, kindConfigure, kindFocus, kindOverrideType: | |||
| 164 | + | // Usually BadWindow: the window died between request and reply and | |||
| 165 | + | // DestroyNotify follows. Whatever the code, the awaited reply will | |||
| 166 | + | // never come, so the map must not wait for it. | |||
| 167 | + | s, rec := x.tab.live(e.Args[0]) | |||
| 168 | + | if rec == nil { | |||
| 169 | + | x.stats.Stale++ | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | switch e.Kind { | |||
| 173 | + | case kindProp: | |||
| 174 | + | rec.pending &^= 1 << e.Args[1] | |||
| 175 | + | case kindGeom: | |||
| 176 | + | rec.pending &^= propGeom | |||
| 177 | + | case kindClientIds: | |||
| 178 | + | rec.pending &^= propClientIDs | |||
| 179 | + | case kindOverrideType: | |||
| 180 | + | if e.Code != xproto.BadWindow { // typed unknown: the window still shows | |||
| 181 | + | x.overrideReady(s) | |||
| 182 | + | } | |||
| 183 | + | return | |||
| 184 | + | default: | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | if e.Code != xproto.BadWindow && e.Code != xproto.BadDrawable { // a dead window is never announced | |||
| 188 | + | x.checkReady(s) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // ownWindow reports whether xid is one of the XWM's own helper windows. | |||
| 194 | + | func (x *XWM) ownWindow(xid uint32) bool { | |||
| 195 | + | return xid != 0 && (xid == x.wmWin || xid == x.selWin || xid == x.xs.win) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // onCreateNotify records a new top-level window. Override-redirect windows | |||
| 199 | + | // are kept (their geometry and serial matter later) but never reach the host. | |||
| 200 | + | func (x *XWM) onCreateNotify(ev *xturbo.Event) { | |||
| 201 | + | e := xproto.CreateNotifyOf(ev) | |||
| 202 | + | xid := uint32(e.Window()) | |||
| 203 | + | if x.ownWindow(xid) || x.tab.lookup(xid) != 0 { | |||
| 204 | + | return | |||
| 205 | + | } | |||
| 206 | + | s := x.insert(xid) | |||
| 207 | + | if s == 0 { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | r := x.tab.rec(s) | |||
| 211 | + | override := e.OverrideRedirect() | |||
| 212 | + | r.x, r.y, r.w, r.h = int32(e.X()), int32(e.Y()), uint32(e.Width()), uint32(e.Height()) | |||
| 213 | + | if override { | |||
| 214 | + | r.kind = kindUnmanaged | |||
| 215 | + | x.stats.UnmanagedSeen++ | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | if x.host != nil { | |||
| 219 | + | x.adopt(s) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // adopt announces a managed window to the host and starts watching it. | |||
| 224 | + | func (x *XWM) adopt(s Slot) { | |||
| 225 | + | r := x.tab.rec(s) | |||
| 226 | + | r.id = x.host.Created(s, r.xid) | |||
| 227 | + | var v xproto.ChangeWindowAttributesValues | |||
| 228 | + | v.SetEventMask(xproto.EventMaskPropertyChange | xproto.EventMaskFocusChange) | |||
| 229 | + | x.expect(xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(r.xid), &v), kindAttr, x.tab.tag(s), 0) | |||
| 230 | + | x.log.Debug().Uint32("xid", r.xid).Uint32("slot", uint32(s)).Msg("x11 window created") | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (x *XWM) onDestroyNotify(ev *xturbo.Event) { | |||
| 234 | + | xid := uint32(xproto.DestroyNotifyOf(ev).Window()) | |||
| 235 | + | s := x.tab.lookup(xid) | |||
| 236 | + | if s == 0 { | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | x.forgetWindow(s) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // onTreeReply (boot): record every existing child and ask for its attributes. | |||
| 243 | + | func (x *XWM) onTreeReply(r *xturbo.Reply) { | |||
| 244 | + | q, ok := xproto.ParseQueryTreeReply(r.Raw) | |||
| 245 | + | if !ok { | |||
| 246 | + | return | |||
| 247 | + | } | |||
| 248 | + | children := q.Children() | |||
| 249 | + | for i := 0; i < children.Len(); i++ { | |||
| 250 | + | xid := uint32(children.At(i)) | |||
| 251 | + | if x.ownWindow(xid) || x.tab.lookup(xid) != 0 { | |||
| 252 | + | continue | |||
| 253 | + | } | |||
| 254 | + | s := x.insert(xid) | |||
| 255 | + | if s == 0 { | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | x.expect(xproto.GetWindowAttributes(x.conn.Buf(), xproto.Window(xid)), kindAttr, x.tab.tag(s), 0) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // onAttrReply (boot): classify an existing window; viewable ones go through | |||
| 263 | + | // the map flow once the host is attached. | |||
| 264 | + | func (x *XWM) onAttrReply(rec *record, r *xturbo.Reply) { | |||
| 265 | + | a, ok := xproto.ParseGetWindowAttributesReply(r.Raw) | |||
| 266 | + | if !ok || x.host != nil { | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | if a.OverrideRedirect() { | |||
| 270 | + | rec.kind = kindUnmanaged | |||
| 271 | + | rec.mapped = a.MapState() == xproto.MapStateViewable | |||
| 272 | + | x.stats.UnmanagedSeen++ | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | if a.MapState() == xproto.MapStateViewable { | |||
| 276 | + | rec.mapped, rec.mapping = true, true | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func (x *XWM) onVersionReply(r *xturbo.Reply) { | |||
| 281 | + | switch r.Args[0] { | |||
| 282 | + | case versionComposite: | |||
| 283 | + | if v, ok := composite.ParseQueryVersionReply(r.Raw); ok { | |||
| 284 | + | x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("Composite") | |||
| 285 | + | } | |||
| 286 | + | case versionXFixes: | |||
| 287 | + | if v, ok := xfixes.ParseQueryVersionReply(r.Raw); ok { | |||
| 288 | + | x.log.Info().Uint32("major", v.MajorVersion()).Uint32("minor", v.MinorVersion()).Msg("XFixes") | |||
| 289 | + | } | |||
| 290 | + | case versionRes: | |||
| 291 | + | if v, ok := res.ParseQueryVersionReply(r.Raw); ok { | |||
| 292 | + | x.log.Info().Uint16("major", v.ServerMajor()).Uint16("minor", v.ServerMinor()).Msg("X-Resource") | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // noteTime records the server time of the latest timestamped event; 0 is | |||
| 298 | + | // CurrentTime and carries none. | |||
| 299 | + | func (x *XWM) noteTime(t uint32) { | |||
| 300 | + | if t != 0 { | |||
| 301 | + | x.lastTime = t | |||
| 302 | + | } | |||
| 303 | + | } | |||
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/loop_mock_test.go +180 -0
| @@ -0,0 +1,180 @@ | |||||
| 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 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newMockloop creates a new instance of mockloop. 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 newMockloop(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *mockloop { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &mockloop{} | |||
| 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 | + | // mockloop is an autogenerated mock type for the loop type | |||
| 37 | + | type mockloop struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type mockloop_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *mockloop) EXPECT() *mockloop_Expecter { | |||
| 46 | + | return &mockloop_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // AddFD provides a mock function for the type mockloop | |||
| 50 | + | func (_mock *mockloop) AddFD(fd int, onReadable func(), onWritable func()) (fdSource, error) { | |||
| 51 | + | ret := _mock.Called(fd, onReadable, onWritable) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for AddFD") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 fdSource | |||
| 58 | + | var r1 error | |||
| 59 | + | if returnFunc, ok := ret.Get(0).(func(int, func(), func()) (fdSource, error)); ok { | |||
| 60 | + | return returnFunc(fd, onReadable, onWritable) | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(0).(func(int, func(), func()) fdSource); ok { | |||
| 63 | + | r0 = returnFunc(fd, onReadable, onWritable) | |||
| 64 | + | } else { | |||
| 65 | + | if ret.Get(0) != nil { | |||
| 66 | + | r0 = ret.Get(0).(fdSource) | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(int, func(), func()) error); ok { | |||
| 70 | + | r1 = returnFunc(fd, onReadable, onWritable) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // mockloop_AddFD_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFD' | |||
| 78 | + | type mockloop_AddFD_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // AddFD is a helper method to define mock.On call | |||
| 83 | + | // - fd int | |||
| 84 | + | // - onReadable func() | |||
| 85 | + | // - onWritable func() | |||
| 86 | + | func (_e *mockloop_Expecter) AddFD(fd any, onReadable any, onWritable any) *mockloop_AddFD_Call { | |||
| 87 | + | return &mockloop_AddFD_Call{Call: _e.mock.On("AddFD", fd, onReadable, onWritable)} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *mockloop_AddFD_Call) Run(run func(fd int, onReadable func(), onWritable func())) *mockloop_AddFD_Call { | |||
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 92 | + | var arg0 int | |||
| 93 | + | if args[0] != nil { | |||
| 94 | + | arg0 = args[0].(int) | |||
| 95 | + | } | |||
| 96 | + | var arg1 func() | |||
| 97 | + | if args[1] != nil { | |||
| 98 | + | arg1 = args[1].(func()) | |||
| 99 | + | } | |||
| 100 | + | var arg2 func() | |||
| 101 | + | if args[2] != nil { | |||
| 102 | + | arg2 = args[2].(func()) | |||
| 103 | + | } | |||
| 104 | + | run( | |||
| 105 | + | arg0, | |||
| 106 | + | arg1, | |||
| 107 | + | arg2, | |||
| 108 | + | ) | |||
| 109 | + | }) | |||
| 110 | + | return _c | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (_c *mockloop_AddFD_Call) Return(fdSourceMoqParam fdSource, err error) *mockloop_AddFD_Call { | |||
| 114 | + | _c.Call.Return(fdSourceMoqParam, err) | |||
| 115 | + | return _c | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (_c *mockloop_AddFD_Call) RunAndReturn(run func(fd int, onReadable func(), onWritable func()) (fdSource, error)) *mockloop_AddFD_Call { | |||
| 119 | + | _c.Call.Return(run) | |||
| 120 | + | return _c | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // After provides a mock function for the type mockloop | |||
| 124 | + | func (_mock *mockloop) After(d time.Duration, fn func()) func() { | |||
| 125 | + | ret := _mock.Called(d, fn) | |||
| 126 | + | ||||
| 127 | + | if len(ret) == 0 { | |||
| 128 | + | panic("no return value specified for After") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | var r0 func() | |||
| 132 | + | if returnFunc, ok := ret.Get(0).(func(time.Duration, func()) func()); ok { | |||
| 133 | + | r0 = returnFunc(d, fn) | |||
| 134 | + | } else { | |||
| 135 | + | if ret.Get(0) != nil { | |||
| 136 | + | r0 = ret.Get(0).(func()) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | return r0 | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // mockloop_After_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'After' | |||
| 143 | + | type mockloop_After_Call struct { | |||
| 144 | + | *mock.Call | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // After is a helper method to define mock.On call | |||
| 148 | + | // - d time.Duration | |||
| 149 | + | // - fn func() | |||
| 150 | + | func (_e *mockloop_Expecter) After(d any, fn any) *mockloop_After_Call { | |||
| 151 | + | return &mockloop_After_Call{Call: _e.mock.On("After", d, fn)} | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func (_c *mockloop_After_Call) Run(run func(d time.Duration, fn func())) *mockloop_After_Call { | |||
| 155 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 156 | + | var arg0 time.Duration | |||
| 157 | + | if args[0] != nil { | |||
| 158 | + | arg0 = args[0].(time.Duration) | |||
| 159 | + | } | |||
| 160 | + | var arg1 func() | |||
| 161 | + | if args[1] != nil { | |||
| 162 | + | arg1 = args[1].(func()) | |||
| 163 | + | } | |||
| 164 | + | run( | |||
| 165 | + | arg0, | |||
| 166 | + | arg1, | |||
| 167 | + | ) | |||
| 168 | + | }) | |||
| 169 | + | return _c | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (_c *mockloop_After_Call) Return(cancel func()) *mockloop_After_Call { | |||
| 173 | + | _c.Call.Return(cancel) | |||
| 174 | + | return _c | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (_c *mockloop_After_Call) RunAndReturn(run func(d time.Duration, fn func()) func()) *mockloop_After_Call { | |||
| 178 | + | _c.Call.Return(run) | |||
| 179 | + | return _c | |||
| 180 | + | } | |||
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 +1050 -0
| @@ -0,0 +1,1050 @@ | |||||
| 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 | + | probe uint32 // gen when the TARGETS ConvertSelection was issued | |||
| 36 | + | owner uint32 // X owner window other than selWin, 0 none | |||
| 37 | + | ownTime uint32 // lastTime when selWin took ownership, answered for TIMESTAMP | |||
| 38 | + | ours bool // selWin owns the selection (Wayland -> X) | |||
| 39 | + | // wl is what the Wayland side offers while ours: raw text availability. | |||
| 40 | + | wlUTF8, wlPlain bool | |||
| 41 | + | wlMime [len(textMimes)]string // offered text MIMEs, in textMimes order, "" absent | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | const ( | |||
| 45 | + | chunk = 65536 // every transfer step moves at most this many bytes | |||
| 46 | + | nXfer = 4 // concurrent transfers per direction, and pool buffers | |||
| 47 | + | tickEvery = time.Second | |||
| 48 | + | xIdleMax = 10 // ticks without X-side progress | |||
| 49 | + | rIdleMax = 30 // ticks of a reader that does not drain its pipe | |||
| 50 | + | ) | |||
| 51 | + | ||||
| 52 | + | // bufPool is the fixed set of transfer buffers. | |||
| 53 | + | type bufPool struct { | |||
| 54 | + | bufs [nXfer][chunk]byte | |||
| 55 | + | free [nXfer]int8 | |||
| 56 | + | n int | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (p *bufPool) init() { | |||
| 60 | + | for i := range p.free { | |||
| 61 | + | p.free[i] = int8(i) | |||
| 62 | + | } | |||
| 63 | + | p.n = nXfer | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // get returns a free buffer index, -1 when the pool is exhausted. | |||
| 67 | + | func (p *bufPool) get() int { | |||
| 68 | + | if p.n == 0 { | |||
| 69 | + | return -1 | |||
| 70 | + | } | |||
| 71 | + | p.n-- | |||
| 72 | + | return int(p.free[p.n]) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (p *bufPool) put(i int) { | |||
| 76 | + | p.free[p.n] = int8(i) | |||
| 77 | + | p.n++ | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // X -> Wayland transfer states. | |||
| 81 | + | const ( | |||
| 82 | + | inFree uint8 = iota | |||
| 83 | + | inQueued // waiting for the conversion property to be free | |||
| 84 | + | inConverting | |||
| 85 | + | inWaitIncr // waiting for the next INCR chunk (PropertyNotify) | |||
| 86 | + | inWriting // a buffer is pending on the Wayland reader's pipe | |||
| 87 | + | inReading // a GetProperty is outstanding | |||
| 88 | + | ) | |||
| 89 | + | ||||
| 90 | + | // xin is one X -> Wayland transfer. | |||
| 91 | + | type xin struct { | |||
| 92 | + | x *XWM | |||
| 93 | + | idx int | |||
| 94 | + | state uint8 | |||
| 95 | + | kind int | |||
| 96 | + | fd int | |||
| 97 | + | target uint32 | |||
| 98 | + | order uint64 | |||
| 99 | + | gen uint32 // stales replies of an aborted transfer | |||
| 100 | + | incr bool | |||
| 101 | + | buf int | |||
| 102 | + | off, n int | |||
| 103 | + | words uint32 // long offset of the next piece of a long property | |||
| 104 | + | more bool // the last piece left bytes in the property | |||
| 105 | + | ready bool // the owner wrote the next INCR chunk while a buffer was pending | |||
| 106 | + | idle int | |||
| 107 | + | src fdSource | |||
| 108 | + | onR func() // pipe hangup | |||
| 109 | + | onW func() // pipe writable | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // selBridge is the XWM's selection state. | |||
| 113 | + | type selBridge struct { | |||
| 114 | + | st [selKinds]selState | |||
| 115 | + | mimes [len(textMimes)]string // scratch handed to SelectionOwned | |||
| 116 | + | pool bufPool | |||
| 117 | + | in [nXfer]xin | |||
| 118 | + | out [nXfer]xout | |||
| 119 | + | order uint64 | |||
| 120 | + | // active is the transfer that owns the conversion property, -1 none. | |||
| 121 | + | active int | |||
| 122 | + | timerOn bool | |||
| 123 | + | closing bool // selClose ran: no transfer may start | |||
| 124 | + | cancel func() | |||
| 125 | + | tickFn func() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (x *XWM) initSelection() { | |||
| 129 | + | b := &x.sel | |||
| 130 | + | b.pool.init() | |||
| 131 | + | b.active = -1 | |||
| 132 | + | b.tickFn = x.selTick | |||
| 133 | + | for i := range b.in { | |||
| 134 | + | t := &b.in[i] | |||
| 135 | + | t.x, t.idx, t.buf = x, i, -1 | |||
| 136 | + | t.onR = func() { t.x.inAbort(t.idx, true) } | |||
| 137 | + | t.onW = func() { t.x.inWritable(t.idx) } | |||
| 138 | + | } | |||
| 139 | + | for i := range b.out { | |||
| 140 | + | o := &b.out[i] | |||
| 141 | + | o.x, o.idx, o.buf, o.fd = x, i, -1, -1 | |||
| 142 | + | o.onR = func() { o.x.outReadable(o.idx) } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // bootSelection creates the selection window and asks for ownership events. | |||
| 147 | + | // Without XFixes the bridge stays off. | |||
| 148 | + | func (x *XWM) bootSelection(root xproto.Window) { | |||
| 149 | + | if !x.conn.Ext().Info(xturbo.ExtXFixes).Present { | |||
| 150 | + | x.log.Warn().Msg("X server has no XFixes: clipboard bridge disabled") | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | xid := x.selWin | |||
| 154 | + | b := x.conn.Buf() | |||
| 155 | + | var wv xproto.CreateWindowValues | |||
| 156 | + | wv.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 157 | + | xproto.CreateWindow(b, 0, xproto.Window(xid), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOnly, 0, &wv) | |||
| 158 | + | x.sel.st[SelClipboard].atom = x.atoms[atomClipboard] | |||
| 159 | + | x.sel.st[SelPrimary].atom = x.atoms[atomPrimary] | |||
| 160 | + | const mask = xfixes.SelectionEventMaskSetSelectionOwner | xfixes.SelectionEventMaskSelectionWindowDestroy | xfixes.SelectionEventMaskSelectionClientClose | |||
| 161 | + | for k := range x.sel.st { | |||
| 162 | + | xfixes.SelectSelectionInput(b, root, xproto.Atom(x.sel.st[k].atom), mask) | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // selKind maps a selection atom to its kind, -1 when it is neither. | |||
| 167 | + | func (x *XWM) selKind(atom uint32) int { | |||
| 168 | + | for k := range x.sel.st { | |||
| 169 | + | if x.sel.st[k].atom == atom && atom != 0 { | |||
| 170 | + | return k | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | return -1 | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // onSelectionEvent handles the selection events; others are ignored. | |||
| 177 | + | func (x *XWM) onSelectionEvent(ev *xturbo.Event) { | |||
| 178 | + | if x.selWin == 0 { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | switch ev.Kind { | |||
| 182 | + | case xfixes.KindSelectionNotify: | |||
| 183 | + | x.onXFixesSelection(ev) | |||
| 184 | + | case xproto.KindSelectionNotify: | |||
| 185 | + | x.onSelectionNotify(ev) | |||
| 186 | + | case xproto.KindSelectionRequest: | |||
| 187 | + | x.onSelectionRequest(ev) | |||
| 188 | + | case xproto.KindSelectionClear: | |||
| 189 | + | x.onSelectionClear(ev) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // onXFixesSelection: the owner of a selection changed. | |||
| 194 | + | func (x *XWM) onXFixesSelection(ev *xturbo.Event) { | |||
| 195 | + | e := xfixes.SelectionNotifyOf(ev) | |||
| 196 | + | k := x.selKind(uint32(e.Selection())) | |||
| 197 | + | if k < 0 { | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | st := &x.sel.st[k] | |||
| 201 | + | owner := uint32(e.Owner()) | |||
| 202 | + | if owner == x.selWin { // our own SetSelectionOwner echoed back | |||
| 203 | + | return | |||
| 204 | + | } | |||
| 205 | + | st.gen++ | |||
| 206 | + | st.owner = owner | |||
| 207 | + | if owner == 0 { | |||
| 208 | + | x.log.Debug().Int("kind", k).Msg("selection: no X owner") | |||
| 209 | + | x.host.SelectionOwned(k, nil) | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | st.probe = st.gen | |||
| 213 | + | x.log.Debug().Int("kind", k).Uint32("owner", owner).Msg("selection: X owner") | |||
| 214 | + | xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(st.atom), | |||
| 215 | + | xproto.Atom(x.atoms[atomTargets]), xproto.Atom(x.atoms[atomNeferwlTargets]), 0) | |||
| 216 | + | x.wake() | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // onSelectionNotify: a ConvertSelection we issued was answered. | |||
| 220 | + | func (x *XWM) onSelectionNotify(ev *xturbo.Event) { | |||
| 221 | + | e := xproto.SelectionNotifyOf(ev) | |||
| 222 | + | if uint32(e.Requestor()) != x.selWin { | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | k := x.selKind(uint32(e.Selection())) | |||
| 226 | + | if k < 0 { | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | if uint32(e.Target()) != x.atoms[atomTargets] { | |||
| 230 | + | x.onDataNotify(e) | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | { | |||
| 234 | + | st := &x.sel.st[k] | |||
| 235 | + | if st.ours || st.owner == 0 || st.probe != st.gen { // stale probe | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | if e.Property() == 0 { | |||
| 239 | + | x.host.SelectionOwned(k, nil) | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlTargets]), 0, 0, 256) | |||
| 243 | + | x.expect(seq, kindSelTargets, uint32(k), x.sel.st[k].gen) | |||
| 244 | + | x.wake() | |||
| 245 | + | return | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // onTargetsReply turns the owner's TARGETS list into the MIME types offered | |||
| 250 | + | // to Wayland: the text subset, empty for images, files and the like. | |||
| 251 | + | func (x *XWM) onTargetsReply(r *xturbo.Reply) { | |||
| 252 | + | k := int(r.Args[0]) | |||
| 253 | + | if k < 0 || k >= selKinds || r.Args[1] != x.sel.st[k].gen || x.sel.st[k].ours || x.sel.st[k].owner == 0 || x.sel.st[k].probe != r.Args[1] { | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | g, ok := xproto.ParseGetPropertyReply(r.Raw) | |||
| 257 | + | if !ok { | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | var have [atomCount]bool | |||
| 261 | + | if g.Format() == 32 { | |||
| 262 | + | v := g.Value() | |||
| 263 | + | for i := 0; i+4 <= len(v); i += 4 { | |||
| 264 | + | a := binary.LittleEndian.Uint32(v[i:]) | |||
| 265 | + | for _, t := range [...]int{atomUTF8String, atomString, atomText} { | |||
| 266 | + | if a == x.atoms[t] { | |||
| 267 | + | have[t] = true | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | n := 0 | |||
| 273 | + | for i, m := range textMimes { | |||
| 274 | + | if have[mimeTarget[i]] { | |||
| 275 | + | x.sel.mimes[n] = m | |||
| 276 | + | n++ | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | if n == 0 { | |||
| 280 | + | x.host.SelectionOwned(k, nil) | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | x.host.SelectionOwned(k, x.sel.mimes[:n]) | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // ---- X -> Wayland ---- | |||
| 287 | + | ||||
| 288 | + | // SelectionReceive: a Wayland reader wants the X selection kind as mime. The | |||
| 289 | + | // XWM takes fd (a write end it now owns) and closes it when done. | |||
| 290 | + | func (x *XWM) SelectionReceive(kind int, mime string, fd int) { | |||
| 291 | + | target := uint32(0) | |||
| 292 | + | for i, m := range textMimes { | |||
| 293 | + | if m == mime { | |||
| 294 | + | target = x.atoms[mimeTarget[i]] | |||
| 295 | + | break | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || target == 0 || x.sel.st[kind].owner == 0 { | |||
| 299 | + | _ = unix.Close(fd) | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 302 | + | if unix.SetNonblock(fd, true) != nil { | |||
| 303 | + | _ = unix.Close(fd) | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | for i := range x.sel.in { | |||
| 307 | + | t := &x.sel.in[i] | |||
| 308 | + | if t.state != inFree { | |||
| 309 | + | continue | |||
| 310 | + | } | |||
| 311 | + | x.sel.order++ | |||
| 312 | + | t.state, t.kind, t.fd, t.target, t.order = inQueued, kind, fd, target, x.sel.order | |||
| 313 | + | 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 | |||
| 314 | + | x.selEnsureTimer() | |||
| 315 | + | x.inStartNext() | |||
| 316 | + | return | |||
| 317 | + | } | |||
| 318 | + | x.log.Warn().Msg("selection: too many transfers, paste refused") | |||
| 319 | + | _ = unix.Close(fd) | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // inStartNext starts the oldest queued transfer when none is converting. | |||
| 323 | + | func (x *XWM) inStartNext() { | |||
| 324 | + | b := &x.sel | |||
| 325 | + | if b.active >= 0 || b.closing { | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 328 | + | best := -1 | |||
| 329 | + | for i := range b.in { | |||
| 330 | + | if b.in[i].state == inQueued && (best < 0 || b.in[i].order < b.in[best].order) { | |||
| 331 | + | best = i | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | if best < 0 { | |||
| 335 | + | return | |||
| 336 | + | } | |||
| 337 | + | t := &b.in[best] | |||
| 338 | + | t.state, t.idle = inConverting, 0 | |||
| 339 | + | b.active = best | |||
| 340 | + | xproto.ConvertSelection(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(b.st[t.kind].atom), | |||
| 341 | + | xproto.Atom(t.target), xproto.Atom(x.atoms[atomNeferwlData]), 0) | |||
| 342 | + | x.wake() | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // inFinish ends transfer i and starts the next one. | |||
| 346 | + | func (x *XWM) inFinish(i int) { | |||
| 347 | + | b := &x.sel | |||
| 348 | + | t := &b.in[i] | |||
| 349 | + | if t.src != nil { | |||
| 350 | + | t.src.Remove() | |||
| 351 | + | t.src = nil | |||
| 352 | + | } | |||
| 353 | + | if t.fd >= 0 { | |||
| 354 | + | _ = unix.Close(t.fd) | |||
| 355 | + | } | |||
| 356 | + | if t.buf >= 0 { | |||
| 357 | + | b.pool.put(t.buf) | |||
| 358 | + | t.buf = -1 | |||
| 359 | + | } | |||
| 360 | + | t.gen++ | |||
| 361 | + | t.state, t.fd = inFree, -1 | |||
| 362 | + | if b.active == i { | |||
| 363 | + | b.active = -1 | |||
| 364 | + | } | |||
| 365 | + | x.inStartNext() | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | // inAbort drops transfer i, deleting the conversion property when X may still | |||
| 369 | + | // be writing it. | |||
| 370 | + | func (x *XWM) inAbort(i int, del bool) { | |||
| 371 | + | t := &x.sel.in[i] | |||
| 372 | + | if t.state == inFree { | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | if del && x.sel.active == i && !x.closed { | |||
| 376 | + | xproto.DeleteProperty(x.conn.Buf(), xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData])) | |||
| 377 | + | x.wake() | |||
| 378 | + | } | |||
| 379 | + | x.inFinish(i) | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // onDataNotify: the owner answered our data ConvertSelection. | |||
| 383 | + | func (x *XWM) onDataNotify(e xproto.SelectionNotifyEvent) { | |||
| 384 | + | i := x.sel.active | |||
| 385 | + | if i < 0 { | |||
| 386 | + | return | |||
| 387 | + | } | |||
| 388 | + | t := &x.sel.in[i] | |||
| 389 | + | if t.state != inConverting || uint32(e.Target()) != t.target || uint32(e.Selection()) != x.sel.st[t.kind].atom { | |||
| 390 | + | return | |||
| 391 | + | } | |||
| 392 | + | if p := uint32(e.Property()); p != 0 && p != x.atoms[atomNeferwlData] { | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | if e.Property() == 0 { | |||
| 396 | + | x.inFinish(i) | |||
| 397 | + | return | |||
| 398 | + | } | |||
| 399 | + | t.words = 0 | |||
| 400 | + | x.inRequest(i) | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | // inRequest asks for the next piece of the property; delete makes INCR and | |||
| 404 | + | // whole-value reads advance. | |||
| 405 | + | func (x *XWM) inRequest(i int) { | |||
| 406 | + | t := &x.sel.in[i] | |||
| 407 | + | t.state = inReading | |||
| 408 | + | seq := xproto.GetProperty(x.conn.Buf(), true, xproto.Window(x.selWin), xproto.Atom(x.atoms[atomNeferwlData]), | |||
| 409 | + | 0, t.words, chunk/4) | |||
| 410 | + | if seq != 0 { | |||
| 411 | + | if err := x.conn.Expect(seq, kindSelData, uint32(i), t.gen, 0, 0); err != nil { | |||
| 412 | + | x.stats.RingFull++ | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | x.wake() | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // onSelDataReply handles one piece of the converted property. | |||
| 419 | + | func (x *XWM) onSelDataReply(r *xturbo.Reply) { | |||
| 420 | + | i := int(r.Args[0]) | |||
| 421 | + | if i < 0 || i >= nXfer { | |||
| 422 | + | return | |||
| 423 | + | } | |||
| 424 | + | t := &x.sel.in[i] | |||
| 425 | + | if t.state != inReading || t.gen != r.Args[1] { | |||
| 426 | + | return | |||
| 427 | + | } | |||
| 428 | + | g, ok := xproto.ParseGetPropertyReply(r.Raw) | |||
| 429 | + | if !ok || g.Format() == 0 { | |||
| 430 | + | x.inAbort(i, false) | |||
| 431 | + | return | |||
| 432 | + | } | |||
| 433 | + | t.idle = 0 | |||
| 434 | + | if uint32(g.Type()) == x.atoms[atomIncr] { | |||
| 435 | + | t.incr, t.state, t.words, t.more = true, inWaitIncr, 0, false | |||
| 436 | + | return | |||
| 437 | + | } | |||
| 438 | + | v := g.Value() | |||
| 439 | + | if g.Format() != 8 || len(v) > chunk { | |||
| 440 | + | x.inAbort(i, true) | |||
| 441 | + | return | |||
| 442 | + | } | |||
| 443 | + | t.more = g.BytesAfter() > 0 | |||
| 444 | + | if len(v) == 0 { | |||
| 445 | + | if t.more { // cannot happen: bytes remain only after a non-empty read | |||
| 446 | + | x.inAbort(i, true) | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | x.inNext(i) | |||
| 450 | + | return | |||
| 451 | + | } | |||
| 452 | + | b := x.sel.pool.get() | |||
| 453 | + | if b < 0 { | |||
| 454 | + | x.log.Warn().Msg("selection: no free buffer, paste aborted") | |||
| 455 | + | x.inAbort(i, true) | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | t.buf = b | |||
| 459 | + | t.n = copy(x.sel.pool.bufs[b][:], v) | |||
| 460 | + | t.off = 0 | |||
| 461 | + | t.words += uint32(t.n) / 4 | |||
| 462 | + | t.state = inWriting | |||
| 463 | + | x.inDrain(i) | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | // inDrain writes the pending buffer to the reader's pipe. | |||
| 467 | + | func (x *XWM) inDrain(i int) { | |||
| 468 | + | t := &x.sel.in[i] | |||
| 469 | + | buf := &x.sel.pool.bufs[t.buf] | |||
| 470 | + | for t.off < t.n { | |||
| 471 | + | n, err := unix.Write(t.fd, buf[t.off:t.n]) | |||
| 472 | + | switch { | |||
| 473 | + | case err == nil: | |||
| 474 | + | t.off += n | |||
| 475 | + | t.idle = 0 | |||
| 476 | + | case errors.Is(err, unix.EINTR): | |||
| 477 | + | case errors.Is(err, unix.EAGAIN): | |||
| 478 | + | x.inWatch(i, true) | |||
| 479 | + | return | |||
| 480 | + | default: // EPIPE: the reader went away | |||
| 481 | + | x.inAbort(i, true) | |||
| 482 | + | return | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | x.sel.pool.put(t.buf) | |||
| 486 | + | t.buf = -1 | |||
| 487 | + | if t.src != nil { | |||
| 488 | + | x.inWatch(i, false) | |||
| 489 | + | } | |||
| 490 | + | x.inNext(i) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | // inWatch arms or disarms writable interest on the reader's pipe. | |||
| 494 | + | func (x *XWM) inWatch(i int, on bool) { | |||
| 495 | + | t := &x.sel.in[i] | |||
| 496 | + | if t.src == nil { | |||
| 497 | + | if !on || x.lp == nil { | |||
| 498 | + | if on { | |||
| 499 | + | x.inAbort(i, true) | |||
| 500 | + | } | |||
| 501 | + | return | |||
| 502 | + | } | |||
| 503 | + | src, err := x.lp.AddFD(t.fd, t.onR, t.onW) | |||
| 504 | + | if err != nil { | |||
| 505 | + | x.inAbort(i, true) | |||
| 506 | + | return | |||
| 507 | + | } | |||
| 508 | + | t.src = src | |||
| 509 | + | } | |||
| 510 | + | if err := t.src.SetWritable(on); err != nil { | |||
| 511 | + | x.inAbort(i, true) | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | // inWritable: the pipe has room again. | |||
| 516 | + | func (x *XWM) inWritable(i int) { | |||
| 517 | + | t := &x.sel.in[i] | |||
| 518 | + | if t.state == inWriting && t.buf >= 0 { | |||
| 519 | + | x.inDrain(i) | |||
| 520 | + | } | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // inNext decides what follows a drained (or empty) piece. | |||
| 524 | + | func (x *XWM) inNext(i int) { | |||
| 525 | + | t := &x.sel.in[i] | |||
| 526 | + | switch { | |||
| 527 | + | case t.more: // the rest of this property | |||
| 528 | + | x.inRequest(i) | |||
| 529 | + | case t.incr && t.n > 0: | |||
| 530 | + | t.state, t.n, t.words = inWaitIncr, 0, 0 | |||
| 531 | + | if t.ready { | |||
| 532 | + | t.ready = false | |||
| 533 | + | x.inRequest(i) | |||
| 534 | + | } | |||
| 535 | + | default: // single value done, or the empty INCR terminator | |||
| 536 | + | x.inFinish(i) | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // onSelProperty: PropertyNotify on the selection window. | |||
| 541 | + | func (x *XWM) onSelProperty(ev *xturbo.Event) { | |||
| 542 | + | e := xproto.PropertyNotifyOf(ev) | |||
| 543 | + | if e.State() != xproto.PropertyNewValue || uint32(e.Atom()) != x.atoms[atomNeferwlData] { | |||
| 544 | + | return | |||
| 545 | + | } | |||
| 546 | + | // (a Deleted state on a requestor is handled by onRequestorProperty) | |||
| 547 | + | i := x.sel.active | |||
| 548 | + | if i < 0 || !x.sel.in[i].incr { | |||
| 549 | + | return | |||
| 550 | + | } | |||
| 551 | + | t := &x.sel.in[i] | |||
| 552 | + | switch t.state { | |||
| 553 | + | case inWaitIncr: | |||
| 554 | + | t.idle = 0 | |||
| 555 | + | t.n = 0 | |||
| 556 | + | x.inRequest(i) | |||
| 557 | + | case inWriting, inReading: | |||
| 558 | + | t.ready = true // no request while a buffer is pending: backpressure | |||
| 559 | + | } | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | // onSelError: a selection request failed; the awaited reply never comes. | |||
| 563 | + | func (x *XWM) onSelError(e *xturbo.XError) { | |||
| 564 | + | if e.Kind == kindSelOut { | |||
| 565 | + | if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.out[i].gen == e.Args[1] && x.sel.out[i].state != outFree { | |||
| 566 | + | x.outAbort(i) | |||
| 567 | + | } | |||
| 568 | + | return | |||
| 569 | + | } | |||
| 570 | + | if e.Kind == kindSelData { | |||
| 571 | + | if i := int(e.Args[0]); i >= 0 && i < nXfer && x.sel.in[i].gen == e.Args[1] && x.sel.in[i].state == inReading { | |||
| 572 | + | x.inAbort(i, false) | |||
| 573 | + | } | |||
| 574 | + | } | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | // ---- timeouts ---- | |||
| 578 | + | ||||
| 579 | + | func (x *XWM) selEnsureTimer() { | |||
| 580 | + | b := &x.sel | |||
| 581 | + | if b.timerOn || x.lp == nil { | |||
| 582 | + | return | |||
| 583 | + | } | |||
| 584 | + | b.timerOn = true | |||
| 585 | + | b.cancel = x.lp.After(tickEvery, b.tickFn) | |||
| 586 | + | } | |||
| 587 | + | ||||
| 588 | + | // selTick ages every transfer by one second and aborts the stalled ones. | |||
| 589 | + | func (x *XWM) selTick() { | |||
| 590 | + | b := &x.sel | |||
| 591 | + | b.timerOn, b.cancel = false, nil | |||
| 592 | + | if x.closed { | |||
| 593 | + | return | |||
| 594 | + | } | |||
| 595 | + | live := false | |||
| 596 | + | for i := range b.in { | |||
| 597 | + | t := &b.in[i] | |||
| 598 | + | if t.state == inFree { | |||
| 599 | + | continue | |||
| 600 | + | } | |||
| 601 | + | t.idle++ | |||
| 602 | + | limit := xIdleMax | |||
| 603 | + | if t.state == inWriting { | |||
| 604 | + | limit = rIdleMax | |||
| 605 | + | } | |||
| 606 | + | if t.state != inQueued && t.idle >= limit { | |||
| 607 | + | x.log.Warn().Int("kind", t.kind).Msg("selection: transfer timed out") | |||
| 608 | + | x.inAbort(i, true) | |||
| 609 | + | continue | |||
| 610 | + | } | |||
| 611 | + | live = true | |||
| 612 | + | } | |||
| 613 | + | for i := range b.out { | |||
| 614 | + | o := &b.out[i] | |||
| 615 | + | if o.state == outFree { | |||
| 616 | + | continue | |||
| 617 | + | } | |||
| 618 | + | o.idle++ | |||
| 619 | + | limit := rIdleMax | |||
| 620 | + | if o.incr && o.n > 0 && !o.credit { | |||
| 621 | + | limit = xIdleMax // the requestor does not delete the property | |||
| 622 | + | } | |||
| 623 | + | if o.idle >= limit { | |||
| 624 | + | x.log.Warn().Int("kind", o.kind).Msg("selection: transfer timed out") | |||
| 625 | + | x.outAbort(i) | |||
| 626 | + | continue | |||
| 627 | + | } | |||
| 628 | + | live = true | |||
| 629 | + | } | |||
| 630 | + | if live { | |||
| 631 | + | x.selEnsureTimer() | |||
| 632 | + | } | |||
| 633 | + | } | |||
| 634 | + | ||||
| 635 | + | // selClose aborts every transfer. | |||
| 636 | + | func (x *XWM) selClose() { | |||
| 637 | + | x.sel.closing = true | |||
| 638 | + | for i := range x.sel.out { | |||
| 639 | + | if x.sel.out[i].state != outFree { | |||
| 640 | + | x.outAbort(i) | |||
| 641 | + | } | |||
| 642 | + | } | |||
| 643 | + | for i := range x.sel.in { | |||
| 644 | + | if x.sel.in[i].state != inFree { | |||
| 645 | + | x.inAbort(i, false) | |||
| 646 | + | } | |||
| 647 | + | } | |||
| 648 | + | if x.sel.cancel != nil { | |||
| 649 | + | x.sel.cancel() | |||
| 650 | + | x.sel.cancel = nil | |||
| 651 | + | } | |||
| 652 | + | x.sel.timerOn = false | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | // ---- Wayland -> X ---- | |||
| 656 | + | ||||
| 657 | + | // Wayland -> X transfer states. | |||
| 658 | + | const ( | |||
| 659 | + | outFree uint8 = iota | |||
| 660 | + | outActive | |||
| 661 | + | ) | |||
| 662 | + | ||||
| 663 | + | // xout is one Wayland -> X transfer. | |||
| 664 | + | type xout struct { | |||
| 665 | + | x *XWM | |||
| 666 | + | idx int | |||
| 667 | + | state uint8 | |||
| 668 | + | kind int | |||
| 669 | + | requestor uint32 | |||
| 670 | + | property uint32 | |||
| 671 | + | target uint32 | |||
| 672 | + | time uint32 | |||
| 673 | + | fd int | |||
| 674 | + | buf int | |||
| 675 | + | n int | |||
| 676 | + | eof bool | |||
| 677 | + | incr bool // INCR announced | |||
| 678 | + | credit bool // the requestor deleted the property: the next chunk may go | |||
| 679 | + | notified bool // the requestor got its SelectionNotify | |||
| 680 | + | idle int | |||
| 681 | + | gen uint32 | |||
| 682 | + | src fdSource | |||
| 683 | + | onR func() | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | // WaylandSelection: a Wayland client owns kind with these MIME types, or none | |||
| 687 | + | // does (mimes nil). The XWM takes X ownership when text is offered. | |||
| 688 | + | func (x *XWM) WaylandSelection(kind int, mimes []string) { | |||
| 689 | + | if x.selWin == 0 || x.closed || kind < 0 || kind >= selKinds || x.host == nil { | |||
| 690 | + | return | |||
| 691 | + | } | |||
| 692 | + | st := &x.sel.st[kind] | |||
| 693 | + | st.wlUTF8, st.wlPlain = false, false | |||
| 694 | + | st.wlMime = [len(textMimes)]string{} | |||
| 695 | + | for _, m := range mimes { | |||
| 696 | + | for i, tm := range textMimes { | |||
| 697 | + | if m != tm { | |||
| 698 | + | continue | |||
| 699 | + | } | |||
| 700 | + | st.wlMime[i] = m | |||
| 701 | + | if mimeTarget[i] == atomUTF8String { | |||
| 702 | + | st.wlUTF8 = true | |||
| 703 | + | } else { | |||
| 704 | + | st.wlPlain = true | |||
| 705 | + | } | |||
| 706 | + | } | |||
| 707 | + | } | |||
| 708 | + | for i := range x.sel.out { // the data being served is stale | |||
| 709 | + | if x.sel.out[i].state != outFree && x.sel.out[i].kind == kind { | |||
| 710 | + | x.outAbort(i) | |||
| 711 | + | } | |||
| 712 | + | } | |||
| 713 | + | b := x.conn.Buf() | |||
| 714 | + | if !st.wlUTF8 && !st.wlPlain { | |||
| 715 | + | if st.ours { | |||
| 716 | + | st.ours = false | |||
| 717 | + | xproto.SetSelectionOwner(b, 0, xproto.Atom(st.atom), 0) | |||
| 718 | + | x.wake() | |||
| 719 | + | } | |||
| 720 | + | return | |||
| 721 | + | } | |||
| 722 | + | st.ours = true | |||
| 723 | + | st.ownTime = x.lastTime | |||
| 724 | + | st.gen++ | |||
| 725 | + | st.owner = 0 | |||
| 726 | + | xproto.SetSelectionOwner(b, xproto.Window(x.selWin), xproto.Atom(st.atom), 0) | |||
| 727 | + | x.log.Debug().Int("kind", kind).Msg("selection: Wayland owner") | |||
| 728 | + | x.wake() | |||
| 729 | + | } | |||
| 730 | + | ||||
| 731 | + | // onSelectionClear: someone else took a selection from selWin. | |||
| 732 | + | func (x *XWM) onSelectionClear(ev *xturbo.Event) { | |||
| 733 | + | e := xproto.SelectionClearOf(ev) | |||
| 734 | + | k := x.selKind(uint32(e.Selection())) | |||
| 735 | + | if k < 0 || uint32(e.Owner()) != x.selWin { | |||
| 736 | + | return | |||
| 737 | + | } | |||
| 738 | + | x.sel.st[k].ours = false | |||
| 739 | + | for i := range x.sel.out { | |||
| 740 | + | if x.sel.out[i].state != outFree && x.sel.out[i].kind == k { | |||
| 741 | + | x.outAbort(i) | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | ||||
| 746 | + | // notify sends a SelectionNotify to the requestor; property 0 refuses. | |||
| 747 | + | func (x *XWM) notify(requestor, selection, target, property, time uint32) { | |||
| 748 | + | d := xproto.SelectionNotifyEventData{ | |||
| 749 | + | Time: xproto.Timestamp(time), Requestor: xproto.Window(requestor), | |||
| 750 | + | Selection: xproto.Atom(selection), Target: xproto.Atom(target), Property: xproto.Atom(property), | |||
| 751 | + | } | |||
| 752 | + | var ev [32]byte | |||
| 753 | + | d.Put(&ev) | |||
| 754 | + | xproto.SendEvent(x.conn.Buf(), false, xproto.Window(requestor), 0, &ev) | |||
| 755 | + | x.wake() | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | // onSelectionRequest: an X client asks selWin for the Wayland selection. | |||
| 759 | + | func (x *XWM) onSelectionRequest(ev *xturbo.Event) { | |||
| 760 | + | e := xproto.SelectionRequestOf(ev) | |||
| 761 | + | if uint32(e.Owner()) != x.selWin { | |||
| 762 | + | return | |||
| 763 | + | } | |||
| 764 | + | sel, req, target, prop, time := uint32(e.Selection()), uint32(e.Requestor()), uint32(e.Target()), uint32(e.Property()), uint32(e.Time()) | |||
| 765 | + | k := x.selKind(sel) | |||
| 766 | + | if k < 0 || !x.sel.st[k].ours { | |||
| 767 | + | x.notify(req, sel, target, 0, time) | |||
| 768 | + | return | |||
| 769 | + | } | |||
| 770 | + | if prop == 0 { // obsolete client | |||
| 771 | + | prop = target | |||
| 772 | + | } | |||
| 773 | + | st := &x.sel.st[k] | |||
| 774 | + | switch target { | |||
| 775 | + | case x.atoms[atomTargets]: | |||
| 776 | + | var list [6]uint32 | |||
| 777 | + | n := 0 | |||
| 778 | + | list[n], n = x.atoms[atomTargets], n+1 | |||
| 779 | + | list[n], n = x.atoms[atomTimestamp], n+1 | |||
| 780 | + | if st.wlUTF8 { | |||
| 781 | + | list[n], n = x.atoms[atomUTF8String], n+1 | |||
| 782 | + | list[n], n = x.atoms[atomText], n+1 | |||
| 783 | + | } | |||
| 784 | + | list[n], n = x.atoms[atomString], n+1 | |||
| 785 | + | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(req), xproto.Atom(prop), | |||
| 786 | + | xproto.AtomAtom, 32, uint32(n), x.encode32(list[:n])) | |||
| 787 | + | x.notify(req, sel, target, prop, time) | |||
| 788 | + | return | |||
| 789 | + | case x.atoms[atomTimestamp]: | |||
| 790 | + | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(req), xproto.Atom(prop), | |||
| 791 | + | xproto.AtomInteger, 32, 1, x.encode32([]uint32{st.ownTime})) | |||
| 792 | + | x.notify(req, sel, target, prop, time) | |||
| 793 | + | return | |||
| 794 | + | case x.atoms[atomUTF8String], x.atoms[atomText], x.atoms[atomString]: | |||
| 795 | + | default: | |||
| 796 | + | x.notify(req, sel, target, 0, time) | |||
| 797 | + | return | |||
| 798 | + | } | |||
| 799 | + | // Choose the Wayland MIME: UTF-8 targets prefer the UTF-8 types; STRING | |||
| 800 | + | // prefers plain text and falls back to the UTF-8 bytes unchanged. | |||
| 801 | + | mime := "" | |||
| 802 | + | order := [...]int{0, 2, 4, 1, 3} // utf8 types, then plain | |||
| 803 | + | if target == x.atoms[atomString] { | |||
| 804 | + | order = [...]int{1, 3, 0, 2, 4} | |||
| 805 | + | } | |||
| 806 | + | for _, i := range order { | |||
| 807 | + | if st.wlMime[i] != "" && (target == x.atoms[atomString] || mimeTarget[i] == atomUTF8String) { | |||
| 808 | + | mime = st.wlMime[i] | |||
| 809 | + | break | |||
| 810 | + | } | |||
| 811 | + | } | |||
| 812 | + | if mime == "" { | |||
| 813 | + | x.notify(req, sel, target, 0, time) | |||
| 814 | + | return | |||
| 815 | + | } | |||
| 816 | + | for i := range x.sel.out { // one transfer per (requestor, property) | |||
| 817 | + | o := &x.sel.out[i] | |||
| 818 | + | if o.state != outFree && o.requestor == req && o.property == prop { | |||
| 819 | + | x.notify(req, sel, target, 0, time) | |||
| 820 | + | return | |||
| 821 | + | } | |||
| 822 | + | } | |||
| 823 | + | slot := -1 | |||
| 824 | + | for i := range x.sel.out { | |||
| 825 | + | if x.sel.out[i].state == outFree { | |||
| 826 | + | slot = i | |||
| 827 | + | break | |||
| 828 | + | } | |||
| 829 | + | } | |||
| 830 | + | if slot < 0 || x.sel.pool.n == 0 { | |||
| 831 | + | x.log.Warn().Msg("selection: too many transfers, request refused") | |||
| 832 | + | x.notify(req, sel, target, 0, time) | |||
| 833 | + | return | |||
| 834 | + | } | |||
| 835 | + | fd := x.host.SelectionRead(k, mime) | |||
| 836 | + | if fd < 0 { | |||
| 837 | + | x.notify(req, sel, target, 0, time) | |||
| 838 | + | return | |||
| 839 | + | } | |||
| 840 | + | x.startOut(slot, k, req, prop, target, time, fd) | |||
| 841 | + | } | |||
| 842 | + | ||||
| 843 | + | // startOut begins serving the data on fd (a non-blocking read end it now | |||
| 844 | + | // owns) to requestor. | |||
| 845 | + | func (x *XWM) startOut(slot, k int, req, prop, target, time uint32, fd int) { | |||
| 846 | + | o := &x.sel.out[slot] | |||
| 847 | + | o.state, o.kind, o.requestor, o.property, o.target, o.time = outActive, k, req, prop, target, time | |||
| 848 | + | 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 | |||
| 849 | + | x.selEnsureTimer() | |||
| 850 | + | x.outRead(slot) // data may already be waiting | |||
| 851 | + | } | |||
| 852 | + | ||||
| 853 | + | // encode32 packs vals into the shared scratch. | |||
| 854 | + | func (x *XWM) encode32(vals []uint32) []byte { | |||
| 855 | + | n := 4 * len(vals) | |||
| 856 | + | if cap(x.scratch) < n { | |||
| 857 | + | x.scratch = make([]byte, 0, n) | |||
| 858 | + | } | |||
| 859 | + | p := x.scratch[:n] | |||
| 860 | + | for i, v := range vals { | |||
| 861 | + | binary.LittleEndian.PutUint32(p[4*i:], v) | |||
| 862 | + | } | |||
| 863 | + | return p | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | // outWatch registers the pipe's read end with the display. | |||
| 867 | + | func (x *XWM) outWatch(i int) { | |||
| 868 | + | o := &x.sel.out[i] | |||
| 869 | + | if o.src != nil { | |||
| 870 | + | return | |||
| 871 | + | } | |||
| 872 | + | if x.lp == nil { | |||
| 873 | + | x.outAbort(i) | |||
| 874 | + | return | |||
| 875 | + | } | |||
| 876 | + | src, err := x.lp.AddFD(o.fd, o.onR, nil) | |||
| 877 | + | if err != nil { | |||
| 878 | + | x.outAbort(i) | |||
| 879 | + | return | |||
| 880 | + | } | |||
| 881 | + | o.src = src | |||
| 882 | + | } | |||
| 883 | + | ||||
| 884 | + | // outUnwatch drops read interest: the buffer is full and nothing may be read | |||
| 885 | + | // until the requestor consumed the previous chunk. | |||
| 886 | + | func (x *XWM) outUnwatch(i int) { | |||
| 887 | + | o := &x.sel.out[i] | |||
| 888 | + | if o.src != nil { | |||
| 889 | + | o.src.Remove() | |||
| 890 | + | o.src = nil | |||
| 891 | + | } | |||
| 892 | + | } | |||
| 893 | + | ||||
| 894 | + | // outReadable is the pipe's read callback. | |||
| 895 | + | func (x *XWM) outReadable(i int) { | |||
| 896 | + | if x.sel.out[i].state == outFree { | |||
| 897 | + | return | |||
| 898 | + | } | |||
| 899 | + | x.outRead(i) | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | // outRead fills the buffer from the pipe and advances the transfer. | |||
| 903 | + | func (x *XWM) outRead(i int) { | |||
| 904 | + | o := &x.sel.out[i] | |||
| 905 | + | buf := &x.sel.pool.bufs[o.buf] | |||
| 906 | + | for o.n < chunk && !o.eof { | |||
| 907 | + | n, err := unix.Read(o.fd, buf[o.n:]) | |||
| 908 | + | switch { | |||
| 909 | + | case err == nil && n == 0: | |||
| 910 | + | o.eof = true | |||
| 911 | + | case err == nil: | |||
| 912 | + | o.n += n | |||
| 913 | + | o.idle = 0 | |||
| 914 | + | case errors.Is(err, unix.EINTR): | |||
| 915 | + | continue | |||
| 916 | + | case errors.Is(err, unix.EAGAIN): | |||
| 917 | + | x.outPump(i) | |||
| 918 | + | return | |||
| 919 | + | default: | |||
| 920 | + | x.outAbort(i) | |||
| 921 | + | return | |||
| 922 | + | } | |||
| 923 | + | } | |||
| 924 | + | x.outPump(i) | |||
| 925 | + | } | |||
| 926 | + | ||||
| 927 | + | // outPump sends what the state allows. | |||
| 928 | + | func (x *XWM) outPump(i int) { | |||
| 929 | + | o := &x.sel.out[i] | |||
| 930 | + | if o.state == outFree { | |||
| 931 | + | return | |||
| 932 | + | } | |||
| 933 | + | full := o.n == chunk | |||
| 934 | + | if !o.incr { | |||
| 935 | + | switch { | |||
| 936 | + | case o.eof: // whole value | |||
| 937 | + | x.outSetProp(i, o.n) | |||
| 938 | + | x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time) | |||
| 939 | + | x.outFinish(i) | |||
| 940 | + | case full: // does not fit one request: INCR | |||
| 941 | + | o.incr, o.notified = true, true | |||
| 942 | + | var v xproto.ChangeWindowAttributesValues | |||
| 943 | + | v.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 944 | + | xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v) | |||
| 945 | + | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property), | |||
| 946 | + | xproto.Atom(x.atoms[atomIncr]), 32, 1, x.encode32([]uint32{0})) | |||
| 947 | + | x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, o.property, o.time) | |||
| 948 | + | x.outUnwatch(i) | |||
| 949 | + | default: | |||
| 950 | + | x.outWatch(i) | |||
| 951 | + | } | |||
| 952 | + | return | |||
| 953 | + | } | |||
| 954 | + | if o.credit && (full || o.eof) { | |||
| 955 | + | o.credit = false | |||
| 956 | + | o.idle = 0 | |||
| 957 | + | x.outSetProp(i, o.n) | |||
| 958 | + | if o.n == 0 { // the empty chunk ends the transfer | |||
| 959 | + | x.outFinish(i) | |||
| 960 | + | return | |||
| 961 | + | } | |||
| 962 | + | o.n = 0 | |||
| 963 | + | } | |||
| 964 | + | if o.n < chunk && !o.eof { | |||
| 965 | + | x.outWatch(i) | |||
| 966 | + | } else { | |||
| 967 | + | x.outUnwatch(i) | |||
| 968 | + | } | |||
| 969 | + | } | |||
| 970 | + | ||||
| 971 | + | // outSetProp writes the first n buffered bytes as the property value. | |||
| 972 | + | func (x *XWM) outSetProp(i, n int) { | |||
| 973 | + | o := &x.sel.out[i] | |||
| 974 | + | seq := xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(o.requestor), xproto.Atom(o.property), | |||
| 975 | + | xproto.Atom(o.target), 8, uint32(n), x.sel.pool.bufs[o.buf][:n]) | |||
| 976 | + | if seq != 0 { | |||
| 977 | + | if err := x.conn.Expect(seq, kindSelOut, uint32(i), o.gen, 0, 0); err != nil { | |||
| 978 | + | x.stats.RingFull++ | |||
| 979 | + | } | |||
| 980 | + | } | |||
| 981 | + | x.wake() | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | // outFinish ends a completed transfer. | |||
| 985 | + | func (x *XWM) outFinish(i int) { | |||
| 986 | + | o := &x.sel.out[i] | |||
| 987 | + | if o.incr && !x.closed { | |||
| 988 | + | var v xproto.ChangeWindowAttributesValues | |||
| 989 | + | v.SetEventMask(0) | |||
| 990 | + | xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v) | |||
| 991 | + | x.wake() | |||
| 992 | + | } | |||
| 993 | + | x.outRelease(i) | |||
| 994 | + | } | |||
| 995 | + | ||||
| 996 | + | // outRelease frees the transfer's resources. | |||
| 997 | + | func (x *XWM) outRelease(i int) { | |||
| 998 | + | o := &x.sel.out[i] | |||
| 999 | + | x.outUnwatch(i) | |||
| 1000 | + | if o.fd >= 0 { | |||
| 1001 | + | _ = unix.Close(o.fd) | |||
| 1002 | + | } | |||
| 1003 | + | if o.buf >= 0 { | |||
| 1004 | + | x.sel.pool.put(o.buf) | |||
| 1005 | + | } | |||
| 1006 | + | o.fd, o.buf = -1, -1 | |||
| 1007 | + | o.gen++ | |||
| 1008 | + | o.state = outFree | |||
| 1009 | + | } | |||
| 1010 | + | ||||
| 1011 | + | // outAbort ends a transfer early: the requestor is refused (before its | |||
| 1012 | + | // notification) or released (INCR: mask dropped, property deleted). | |||
| 1013 | + | func (x *XWM) outAbort(i int) { | |||
| 1014 | + | o := &x.sel.out[i] | |||
| 1015 | + | if o.state == outFree { | |||
| 1016 | + | return | |||
| 1017 | + | } | |||
| 1018 | + | if !x.closed { | |||
| 1019 | + | switch { | |||
| 1020 | + | case !o.notified: | |||
| 1021 | + | x.notify(o.requestor, x.sel.st[o.kind].atom, o.target, 0, o.time) | |||
| 1022 | + | case o.incr: | |||
| 1023 | + | var v xproto.ChangeWindowAttributesValues | |||
| 1024 | + | v.SetEventMask(0) | |||
| 1025 | + | xproto.ChangeWindowAttributes(x.conn.Buf(), xproto.Window(o.requestor), &v) | |||
| 1026 | + | xproto.DeleteProperty(x.conn.Buf(), xproto.Window(o.requestor), xproto.Atom(o.property)) | |||
| 1027 | + | x.wake() | |||
| 1028 | + | } | |||
| 1029 | + | } | |||
| 1030 | + | x.outRelease(i) | |||
| 1031 | + | } | |||
| 1032 | + | ||||
| 1033 | + | // onRequestorProperty: PropertyNotify on a window we serve by INCR. | |||
| 1034 | + | func (x *XWM) onRequestorProperty(e xproto.PropertyNotifyEvent) bool { | |||
| 1035 | + | if e.State() != xproto.PropertyDelete { | |||
| 1036 | + | return false | |||
| 1037 | + | } | |||
| 1038 | + | hit := false | |||
| 1039 | + | for i := range x.sel.out { | |||
| 1040 | + | o := &x.sel.out[i] | |||
| 1041 | + | if o.state == outFree || !o.incr || o.requestor != uint32(e.Window()) || o.property != uint32(e.Atom()) { | |||
| 1042 | + | continue | |||
| 1043 | + | } | |||
| 1044 | + | hit = true | |||
| 1045 | + | o.credit = true | |||
| 1046 | + | o.idle = 0 | |||
| 1047 | + | x.outPump(i) | |||
| 1048 | + | } | |||
| 1049 | + | return hit | |||
| 1050 | + | } | |||
A internal/adapters/wayland/xwm/selection_test.go +801 -0
| @@ -0,0 +1,801 @@ | |||||
| 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.lp = newFDLoop(t, r, src, nil, &onW) | |||
| 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.lp = newTimerLoop(t) | |||
| 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(¬e) | |||
| 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(¬e) | |||
| 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("TIMESTAMP"), 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.lp = newFDLoop(t, r, src, &onR, 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.lp = newFDLoop(t, r, src, nil, 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.lp = newFDLoop(t, r, src, nil, 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.lp = newTimerLoop(t) | |||
| 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 | + | x.sel.st[SelClipboard].wlMime = [len(textMimes)]string{"text/plain;charset=utf-8"} | |||
| 655 | + | r.h.EXPECT().SelectionRead(SelClipboard, mock.Anything).RunAndReturn(func(int, string) int { return fds[0] }).Maybe() | |||
| 656 | + | // The host mock allocates per call (call recording); its cost is measured | |||
| 657 | + | // alone and subtracted, so the request path itself must add nothing. | |||
| 658 | + | x.post = nil // the test post queue grows by append | |||
| 659 | + | base := testing.AllocsPerRun(100, func() { r.h.Calls = r.h.Calls[:0]; x.host.SelectionRead(SelClipboard, "text/plain;charset=utf-8") }) | |||
| 660 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 661 | + | if unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK) != nil { | |||
| 662 | + | t.Fatal("pipe") | |||
| 663 | + | } | |||
| 664 | + | if _, err := unix.Write(fds[1], data); err != nil { | |||
| 665 | + | t.Fatal("write") | |||
| 666 | + | } | |||
| 667 | + | unix.Close(fds[1]) | |||
| 668 | + | r.h.Calls = r.h.Calls[:0] // the mock's call log would grow | |||
| 669 | + | x.onSelectionRequest(&ev) | |||
| 670 | + | x.posted = false | |||
| 671 | + | x.flush() | |||
| 672 | + | }) | |||
| 673 | + | // Pipe2 itself does not allocate; the measured path is read + ChangeProperty + SendEvent. | |||
| 674 | + | // The slack covers the in-process X peer goroutine reading the flushed bytes. | |||
| 675 | + | require.LessOrEqual(t, allocs, base+8, "request path allocates beyond the host call") | |||
| 676 | + | require.Equal(t, outFree, x.sel.out[0].state) | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | // A TARGETS reply that arrives after Wayland took the selection is dropped. | |||
| 680 | + | func TestSelectionStaleTargetsAfterWaylandOwner(t *testing.T) { | |||
| 681 | + | x, p, h, _, _ := attached(t) | |||
| 682 | + | const owner = 0x500010 | |||
| 683 | + | clip := p.atom("CLIPBOARD") | |||
| 684 | + | tgt := p.atom("TARGETS") | |||
| 685 | + | p.xfixesSelection(owner, clip) | |||
| 686 | + | req, _ := pumpReq(t, x, p, 0, func(r xpeerReq) bool { return r.major == 24 }) | |||
| 687 | + | prop := req.u32(16) | |||
| 688 | + | x.WaylandSelection(SelClipboard, []string{"text/plain"}) | |||
| 689 | + | p.setProp(x.selWin, prop, 4, 32, le32(tgt, p.atom("UTF8_STRING"))) | |||
| 690 | + | p.selectionNotify(x.selWin, clip, tgt, prop) // the mock fails on SelectionOwned | |||
| 691 | + | ev := x.Stats().Events | |||
| 692 | + | readUntil(t, x, func() bool { return x.Stats().Events > ev }) | |||
| 693 | + | x.flush() | |||
| 694 | + | require.True(t, x.sel.st[SelClipboard].ours) | |||
| 695 | + | for i := 0; i < 20; i++ { // let any (wrong) GetProperty round trip finish | |||
| 696 | + | x.onReadable() | |||
| 697 | + | x.flush() | |||
| 698 | + | time.Sleep(time.Millisecond) | |||
| 699 | + | } | |||
| 700 | + | require.Zero(t, getProps(p, 0, x.selWin), "the stale TARGETS reply is not fetched") | |||
| 701 | + | _ = h | |||
| 702 | + | } | |||
| 703 | + | ||||
| 704 | + | // newFDLoop is a loop whose AddFD hands out src and captures the callbacks; | |||
| 705 | + | // timers are inert. | |||
| 706 | + | func newFDLoop(t *testing.T, rig *selRig, src fdSource, onR, onW *func()) *mockloop { | |||
| 707 | + | l := newMockloop(t) | |||
| 708 | + | l.EXPECT().AddFD(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 709 | + | func(_ int, r, w func()) (fdSource, error) { | |||
| 710 | + | if onR != nil { | |||
| 711 | + | *onR = r | |||
| 712 | + | } | |||
| 713 | + | if onW != nil { | |||
| 714 | + | *onW = w | |||
| 715 | + | } | |||
| 716 | + | return src, nil | |||
| 717 | + | }).Maybe() | |||
| 718 | + | l.EXPECT().After(mock.Anything, mock.Anything).RunAndReturn( | |||
| 719 | + | func(_ time.Duration, fn func()) func() { rig.timer = fn; return func() {} }).Maybe() | |||
| 720 | + | return l | |||
| 721 | + | } | |||
| 722 | + | ||||
| 723 | + | // newTimerLoop is a loop with inert timers. | |||
| 724 | + | func newTimerLoop(t *testing.T) *mockloop { | |||
| 725 | + | l := newMockloop(t) | |||
| 726 | + | l.EXPECT().After(mock.Anything, mock.Anything).RunAndReturn( | |||
| 727 | + | func(time.Duration, func()) func() { return func() {} }).Maybe() | |||
| 728 | + | return l | |||
| 729 | + | } | |||
| 730 | + | ||||
| 731 | + | // A late SelectionNotify of an aborted transfer (other selection or property) | |||
| 732 | + | // is not taken by the next transfer. | |||
| 733 | + | func TestSelectionLateReplyIgnored(t *testing.T) { | |||
| 734 | + | r := ownedRig(t) | |||
| 735 | + | _ = r.receive(t, 0) | |||
| 736 | + | _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 }) | |||
| 737 | + | r.skip = n | |||
| 738 | + | for i := 0; i < xIdleMax; i++ { | |||
| 739 | + | r.timer() | |||
| 740 | + | } | |||
| 741 | + | require.Equal(t, inFree, r.x.sel.in[0].state) | |||
| 742 | + | _ = r.receive(t, 0) | |||
| 743 | + | require.Equal(t, inConverting, r.x.sel.in[0].state) | |||
| 744 | + | prim := r.p.atom("PRIMARY") | |||
| 745 | + | r.p.selectionNotify(r.x.selWin, prim, r.utf8, r.data) | |||
| 746 | + | r.p.selectionNotify(r.x.selWin, r.clip, r.utf8, r.p.atom("_NEFERWL_TARGETS")) | |||
| 747 | + | ev := r.x.Stats().Events | |||
| 748 | + | readUntil(t, r.x, func() bool { return r.x.Stats().Events >= ev+2 }) | |||
| 749 | + | require.Equal(t, inConverting, r.x.sel.in[0].state) | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | // CloseConn closes the fds of in-flight transfers. | |||
| 753 | + | func TestSelectionCloseConnClosesFDs(t *testing.T) { | |||
| 754 | + | r := ownedRig(t) | |||
| 755 | + | f := r.receive(t, 0) | |||
| 756 | + | r.x.CloseConn() | |||
| 757 | + | _, err := f.Read(make([]byte, 8)) | |||
| 758 | + | require.ErrorIs(t, err, io.EOF) | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | // Closing never starts a queued transfer's ConvertSelection. | |||
| 762 | + | func TestSelectionCloseDoesNotStartQueued(t *testing.T) { | |||
| 763 | + | r := ownedRig(t) | |||
| 764 | + | _ = r.receive(t, 0) | |||
| 765 | + | _ = r.receive(t, 0) | |||
| 766 | + | _, n := pumpReq(t, r.x, r.p, r.skip, func(q xpeerReq) bool { return q.major == 24 }) | |||
| 767 | + | r.x.selClose() | |||
| 768 | + | r.x.flush() | |||
| 769 | + | time.Sleep(50 * time.Millisecond) | |||
| 770 | + | cs := 0 | |||
| 771 | + | for _, q := range since(r.p, r.skip, 24) { | |||
| 772 | + | cs++ | |||
| 773 | + | _ = q | |||
| 774 | + | } | |||
| 775 | + | _ = n | |||
| 776 | + | require.Equal(t, 1, cs) | |||
| 777 | + | require.False(t, r.x.sel.timerOn) | |||
| 778 | + | } | |||
| 779 | + | ||||
| 780 | + | func TestSelectionWaylandToXTimestamp(t *testing.T) { | |||
| 781 | + | r := ownedByWayland(t, "text/plain") | |||
| 782 | + | r.p.selectionRequest(r.x.selWin, reqWin, r.clip, r.p.atom("TIMESTAMP"), r.data) | |||
| 783 | + | require.Equal(t, r.data, sentNotify(t, r)) | |||
| 784 | + | v, ok := r.p.prop(reqWin, "_NEFERWL_DATA") | |||
| 785 | + | require.True(t, ok) | |||
| 786 | + | require.EqualValues(t, 32, v.format) | |||
| 787 | + | require.EqualValues(t, 19, v.typ, "INTEGER") | |||
| 788 | + | require.Len(t, v.data, 4) | |||
| 789 | + | } | |||
| 790 | + | ||||
| 791 | + | func TestSelectionOwnershipTimeFromLastEvent(t *testing.T) { | |||
| 792 | + | x, p, _, _, _ := attached(t) | |||
| 793 | + | p.event(28, func(b *[32]byte) { // PropertyNotify | |||
| 794 | + | binary.LittleEndian.PutUint32(b[4:], x.selWin) | |||
| 795 | + | binary.LittleEndian.PutUint32(b[8:], 1) | |||
| 796 | + | binary.LittleEndian.PutUint32(b[12:], 4242) | |||
| 797 | + | }) | |||
| 798 | + | readUntil(t, x, func() bool { return x.lastTime == 4242 }) | |||
| 799 | + | x.WaylandSelection(SelClipboard, []string{"text/plain"}) | |||
| 800 | + | require.EqualValues(t, 4242, x.sel.st[SelClipboard].ownTime) | |||
| 801 | + | } | |||
A internal/adapters/wayland/xwm/selection_xsettings.go +198 -0
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "unicode/utf8" | |||
| 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 | + | theme string | |||
| 32 | + | size int32 | |||
| 33 | + | fixedSerial uint32 // serial of the constant settings (scale 1, 96 dpi) | |||
| 34 | + | themeSerial uint32 | |||
| 35 | + | sizeSerial uint32 | |||
| 36 | + | ||||
| 37 | + | buf [xsBufSize]byte | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // X11 clients see outputs at their logical size and the compositor upscales | |||
| 41 | + | // their windows, so the scale announced is always 1 at 96 dpi. | |||
| 42 | + | const xsDPI = 96 * 1024 | |||
| 43 | + | ||||
| 44 | + | func (x *XWM) cursorThemeName() string { | |||
| 45 | + | t := x.cfg.CursorTheme | |||
| 46 | + | if t == "" { | |||
| 47 | + | t = "default" | |||
| 48 | + | } | |||
| 49 | + | if len(t) > xsMaxString { | |||
| 50 | + | n := xsMaxString | |||
| 51 | + | for n > 0 && !utf8.RuneStart(t[n]) { // back off to a rune boundary | |||
| 52 | + | n-- | |||
| 53 | + | } | |||
| 54 | + | t = t[:n] | |||
| 55 | + | } | |||
| 56 | + | return t | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (x *XWM) cursorThemeSize() int32 { | |||
| 60 | + | if s := x.cfg.CursorSize; s >= 8 && s <= 256 { | |||
| 61 | + | return int32(s) | |||
| 62 | + | } | |||
| 63 | + | return 24 | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // bootSettings runs at the end of boot: the XSETTINGS manager and the root | |||
| 67 | + | // cursor. Failures are never fatal. | |||
| 68 | + | func (x *XWM) bootSettings() error { | |||
| 69 | + | if err := x.bootXSettings(); err != nil { | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | return x.bootRootCursor() | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (x *XWM) bootXSettings() error { | |||
| 76 | + | if !x.cfg.XSettings { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | conn, b := x.conn, x.conn.Buf() | |||
| 80 | + | win, err := conn.NewXID() | |||
| 81 | + | if err != nil { | |||
| 82 | + | return err | |||
| 83 | + | } | |||
| 84 | + | x.xs.win = win | |||
| 85 | + | var wv xproto.CreateWindowValues | |||
| 86 | + | wv.SetEventMask(0) | |||
| 87 | + | xproto.CreateWindow(b, 0, xproto.Window(win), xproto.Window(x.root), -1, -1, 1, 1, 0, xproto.WindowClassInputOnly, 0, &wv) | |||
| 88 | + | x.expect(xproto.GetSelectionOwner(b, xproto.Atom(x.atoms[atomXSettingsS0])), kindSelOwner, 0, 0) | |||
| 89 | + | if err := conn.Sync(&x.h); err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | if x.bootErr != nil { | |||
| 93 | + | return x.bootErr | |||
| 94 | + | } | |||
| 95 | + | if x.xs.taken { | |||
| 96 | + | x.log.Warn().Uint32("owner", x.xs.ownerNo).Msg("another XSETTINGS manager runs, not taking _XSETTINGS_S0") | |||
| 97 | + | xproto.DestroyWindow(b, xproto.Window(win)) | |||
| 98 | + | x.xs.win = 0 | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | x.publishXSettings(true) | |||
| 102 | + | sel := x.atoms[atomXSettingsS0] | |||
| 103 | + | xproto.SetSelectionOwner(b, xproto.Window(win), xproto.Atom(sel), 0) | |||
| 104 | + | // ICCCM §2.8: announce the new manager to the root. | |||
| 105 | + | var ev [32]byte | |||
| 106 | + | ev[0], ev[1] = 33, 32 // ClientMessage, format 32 | |||
| 107 | + | binary.LittleEndian.PutUint32(ev[4:], x.root) | |||
| 108 | + | binary.LittleEndian.PutUint32(ev[8:], x.atoms[atomManager]) | |||
| 109 | + | binary.LittleEndian.PutUint32(ev[16:], sel) | |||
| 110 | + | binary.LittleEndian.PutUint32(ev[20:], win) | |||
| 111 | + | xproto.SendEvent(b, false, xproto.Window(x.root), xproto.EventMaskStructureNotify, &ev) | |||
| 112 | + | x.xs.active = true | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // onSelOwnerReply: the boot GetSelectionOwner of _XSETTINGS_S0. | |||
| 117 | + | func (x *XWM) onSelOwnerReply(r *xturbo.Reply) { | |||
| 118 | + | if v, ok := xproto.ParseGetSelectionOwnerReply(r.Raw); ok && v.Owner() != 0 && uint32(v.Owner()) != x.xs.win { | |||
| 119 | + | x.xs.taken, x.xs.ownerNo = true, uint32(v.Owner()) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // onXSettingsClear: another client took _XSETTINGS_S0. | |||
| 124 | + | func (x *XWM) onXSettingsClear(ev *xturbo.Event) { | |||
| 125 | + | e := xproto.SelectionClearEvent(xproto.SelectionClearOf(ev)) | |||
| 126 | + | if x.xs.active && uint32(e.Owner()) == x.xs.win && uint32(e.Selection()) == x.atoms[atomXSettingsS0] { | |||
| 127 | + | x.xs.active = false | |||
| 128 | + | x.log.Warn().Msg("XSETTINGS manager replaced by another client, settings no longer updated") | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // publishXSettings encodes and writes the property when a value changed | |||
| 133 | + | // (force: the first time). It reports whether it wrote. Unchanged values cost | |||
| 134 | + | // no allocation and no request. | |||
| 135 | + | func (x *XWM) publishXSettings(force bool) bool { | |||
| 136 | + | s := &x.xs | |||
| 137 | + | theme, size := x.cursorThemeName(), x.cursorThemeSize() | |||
| 138 | + | if !force && s.serial != 0 && theme == s.theme && size == s.size { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | next := s.serial + 1 // wraps | |||
| 142 | + | if force || s.serial == 0 { | |||
| 143 | + | s.fixedSerial = next | |||
| 144 | + | } | |||
| 145 | + | if force || s.serial == 0 || theme != s.theme { | |||
| 146 | + | s.themeSerial = next | |||
| 147 | + | } | |||
| 148 | + | if force || s.serial == 0 || size != s.size { | |||
| 149 | + | s.sizeSerial = next | |||
| 150 | + | } | |||
| 151 | + | s.serial, s.theme, s.size = next, theme, size | |||
| 152 | + | n := s.encode() | |||
| 153 | + | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(s.win), | |||
| 154 | + | xproto.Atom(x.atoms[atomXSettingsSettings]), xproto.Atom(x.atoms[atomXSettingsSettings]), 8, uint32(n), s.buf[:n]) | |||
| 155 | + | return true | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func pad4(n int) int { return (n + 3) &^ 3 } | |||
| 159 | + | ||||
| 160 | + | // encode writes the settings, sorted by name, into s.buf. | |||
| 161 | + | func (s *xsettings) encode() int { | |||
| 162 | + | le := binary.LittleEndian | |||
| 163 | + | b := s.buf[:] | |||
| 164 | + | clear(b[:16]) | |||
| 165 | + | b[0] = 0 // LSBFirst | |||
| 166 | + | le.PutUint32(b[4:], s.serial) | |||
| 167 | + | le.PutUint32(b[8:], 5) | |||
| 168 | + | n := 12 | |||
| 169 | + | putName := func(typ byte, name string, serial uint32) { | |||
| 170 | + | b[n], b[n+1] = typ, 0 | |||
| 171 | + | le.PutUint16(b[n+2:], uint16(len(name))) | |||
| 172 | + | n += 4 | |||
| 173 | + | copy(b[n:], name) | |||
| 174 | + | clear(b[n+len(name) : n+pad4(len(name))]) | |||
| 175 | + | n += pad4(len(name)) | |||
| 176 | + | le.PutUint32(b[n:], serial) | |||
| 177 | + | n += 4 | |||
| 178 | + | } | |||
| 179 | + | putName(xsInt, "Gdk/UnscaledDPI", s.fixedSerial) | |||
| 180 | + | le.PutUint32(b[n:], xsDPI) | |||
| 181 | + | n += 4 | |||
| 182 | + | putName(xsInt, "Gdk/WindowScalingFactor", s.fixedSerial) | |||
| 183 | + | le.PutUint32(b[n:], 1) | |||
| 184 | + | n += 4 | |||
| 185 | + | putName(xsString, "Gtk/CursorThemeName", s.themeSerial) | |||
| 186 | + | le.PutUint32(b[n:], uint32(len(s.theme))) | |||
| 187 | + | n += 4 | |||
| 188 | + | copy(b[n:], s.theme) | |||
| 189 | + | clear(b[n+len(s.theme) : n+pad4(len(s.theme))]) | |||
| 190 | + | n += pad4(len(s.theme)) | |||
| 191 | + | putName(xsInt, "Gtk/CursorThemeSize", s.sizeSerial) | |||
| 192 | + | le.PutUint32(b[n:], uint32(s.size)) | |||
| 193 | + | n += 4 | |||
| 194 | + | putName(xsInt, "Xft/DPI", s.fixedSerial) | |||
| 195 | + | le.PutUint32(b[n:], xsDPI) | |||
| 196 | + | n += 4 | |||
| 197 | + | return n | |||
| 198 | + | } | |||
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 +197 -0
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/hex" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "runtime" | |||
| 8 | + | "runtime/debug" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "bnema.dev/xturbo/xproto" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // The recorded streams are the bytes an X server wrote to the XWM during a | |||
| 18 | + | // steady-state session of one mapped window, captured from the scripted xpeer | |||
| 19 | + | // (real encoded X packets: events, GetProperty replies). The scenario is | |||
| 20 | + | // deterministic, so a replay meets the request sequence numbers it recorded. | |||
| 21 | + | // They are not xtrace captures of a real Xwayland: xpeer speaks the same wire | |||
| 22 | + | // protocol and keeps the fixture reproducible. Regenerate with | |||
| 23 | + | // | |||
| 24 | + | // XWM_UPDATE_STREAMS=1 go test ./internal/adapters/wayland/xwm -run TestRecordedStreams | |||
| 25 | + | const ( | |||
| 26 | + | streamCycles = 400 | |||
| 27 | + | streamWarm = 20 // cycles replayed before measuring | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | const streamWin = 0x500001 | |||
| 31 | + | ||||
| 32 | + | type streamKind struct { | |||
| 33 | + | name string | |||
| 34 | + | // perCycle is Events+Replies the XWM sees per cycle. | |||
| 35 | + | perCycle int | |||
| 36 | + | cycle func(hs *harness) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | var streamKinds = []streamKind{ | |||
| 40 | + | // A title notification the XWM answers with one GetProperty whose reply | |||
| 41 | + | // equals the stored title. | |||
| 42 | + | {"property_unchanged", 2, func(hs *harness) { | |||
| 43 | + | before := hs.x.Stats().Replies | |||
| 44 | + | hs.p.propertyNotify(streamWin, hs.p.atom("_NET_WM_NAME"), false) | |||
| 45 | + | readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before }) | |||
| 46 | + | }}, | |||
| 47 | + | // The same plus ConfigureNotify, FocusIn and MapNotify. | |||
| 48 | + | {"steady", 5, func(hs *harness) { | |||
| 49 | + | before := hs.x.Stats().Replies | |||
| 50 | + | hs.p.propertyNotify(streamWin, hs.p.atom("_NET_WM_NAME"), false) | |||
| 51 | + | readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Replies > before }) | |||
| 52 | + | events := hs.x.Stats().Events | |||
| 53 | + | hs.p.configureNotify(streamWin) | |||
| 54 | + | hs.p.focusIn(streamWin) | |||
| 55 | + | hs.p.mapNotify(streamWin) | |||
| 56 | + | readUntil(hs.t, hs.x, func() bool { return hs.x.Stats().Events >= events+3 }) | |||
| 57 | + | }}, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // streamScenario boots, maps one window and parks the peer behind a poke so | |||
| 61 | + | // nothing but the test writes to the XWM afterwards (an allocation count is | |||
| 62 | + | // process-wide). The poke is part of the scenario, recorded or replayed. | |||
| 63 | + | func streamScenario(t *testing.T) *harness { | |||
| 64 | + | t.Helper() | |||
| 65 | + | hs := newHarness(t, nil) | |||
| 66 | + | hs.classic(streamWin) | |||
| 67 | + | hs.mapAndWait(streamWin) | |||
| 68 | + | // The steady state is a focused window: a FocusIn on it is not denied, | |||
| 69 | + | // so no request depends on how events are batched into flushes. The | |||
| 70 | + | // poke makes sure the focus requests are out before the peer pauses. | |||
| 71 | + | hs.x.Focus(hs.x.tab.lookup(streamWin)) | |||
| 72 | + | poke(hs) | |||
| 73 | + | return hs | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func poke(hs *harness) { | |||
| 77 | + | hs.t.Helper() | |||
| 78 | + | n := len(hs.p.requests()) | |||
| 79 | + | xproto.GetInputFocus(hs.x.conn.Buf()) | |||
| 80 | + | hs.x.flush() | |||
| 81 | + | hs.p.waitReq(n, func(r xpeerReq) bool { return r.major == opGetInputFocus }) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func recordStream(t *testing.T, k streamKind) []byte { | |||
| 85 | + | hs := streamScenario(t) | |||
| 86 | + | poke(hs) | |||
| 87 | + | hs.p.mu.Lock() | |||
| 88 | + | hs.p.record = true | |||
| 89 | + | hs.p.mu.Unlock() | |||
| 90 | + | for i := 0; i < streamCycles; i++ { | |||
| 91 | + | k.cycle(hs) | |||
| 92 | + | } | |||
| 93 | + | hs.p.mu.Lock() | |||
| 94 | + | defer hs.p.mu.Unlock() | |||
| 95 | + | hs.p.record = false | |||
| 96 | + | return append([]byte(nil), hs.p.tap...) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func streamPath(k streamKind) string { | |||
| 100 | + | return filepath.Join("testdata", "streams", k.name+".hex") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func loadStream(t *testing.T, k streamKind) []byte { | |||
| 104 | + | t.Helper() | |||
| 105 | + | text, err := os.ReadFile(streamPath(k)) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | b, err := hex.DecodeString(strings.Join(strings.Fields(string(text)), "")) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | return b | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func encodeHex(b []byte) string { | |||
| 113 | + | var sb strings.Builder | |||
| 114 | + | for len(b) > 0 { | |||
| 115 | + | n := min(32, len(b)) | |||
| 116 | + | sb.WriteString(hex.EncodeToString(b[:n])) | |||
| 117 | + | sb.WriteByte('\n') | |||
| 118 | + | b = b[n:] | |||
| 119 | + | } | |||
| 120 | + | return sb.String() | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestRecordedStreams(t *testing.T) { | |||
| 124 | + | for _, k := range streamKinds { | |||
| 125 | + | t.Run(k.name, func(t *testing.T) { | |||
| 126 | + | got := recordStream(t, k) | |||
| 127 | + | if os.Getenv("XWM_UPDATE_STREAMS") != "" { | |||
| 128 | + | require.NoError(t, os.MkdirAll(filepath.Dir(streamPath(k)), 0o755)) | |||
| 129 | + | require.NoError(t, os.WriteFile(streamPath(k), []byte(encodeHex(got)), 0o644)) | |||
| 130 | + | } | |||
| 131 | + | require.Equal(t, loadStream(t, k), got, "the committed stream no longer matches the protocol the XWM speaks") | |||
| 132 | + | }) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // replay feeds the recorded stream and reports the mallocs of the part after | |||
| 137 | + | // the warm-up cycles. | |||
| 138 | + | func replay(t *testing.T, k streamKind) (mallocs uint64, hs *harness) { | |||
| 139 | + | t.Helper() | |||
| 140 | + | stream := loadStream(t, k) | |||
| 141 | + | hs = streamScenario(t) | |||
| 142 | + | hs.p.pause() | |||
| 143 | + | // A peer blocked in its read consumes one more request before it parks | |||
| 144 | + | // (this one, or an earlier one still in flight); a parked peer leaves it | |||
| 145 | + | // unread. Either way the peer ends up parked, with nothing half read. | |||
| 146 | + | xproto.GetInputFocus(hs.x.conn.Buf()) | |||
| 147 | + | hs.x.flush() | |||
| 148 | + | hs.p.waitParked() | |||
| 149 | + | _, err := hs.p.f.Write(stream) | |||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | ||||
| 152 | + | x := hs.x | |||
| 153 | + | // The harness post queue grows by append, at batching-dependent moments. | |||
| 154 | + | x.post = func(func()) {} | |||
| 155 | + | base := x.Stats().Events + x.Stats().Replies | |||
| 156 | + | drive := func(until uint64) { | |||
| 157 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 158 | + | for x.Stats().Events+x.Stats().Replies < until { | |||
| 159 | + | if time.Now().After(deadline) { // no formatting arguments: boxing them would count as mallocs | |||
| 160 | + | t.Fatal("stream stalled") | |||
| 161 | + | } | |||
| 162 | + | before := x.Stats().Events + x.Stats().Replies | |||
| 163 | + | x.onReadable() | |||
| 164 | + | if x.Stats().Events+x.Stats().Replies == before { | |||
| 165 | + | runtime.Gosched() | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | drive(base + uint64(streamWarm*k.perCycle)) | |||
| 170 | + | target := base + uint64(streamCycles*k.perCycle) | |||
| 171 | + | ||||
| 172 | + | var m0, m1 runtime.MemStats | |||
| 173 | + | // A GC cycle finishing inside the window allocates runtime-internal | |||
| 174 | + | // objects (sudogs), which Mallocs counts: keep the collector off. | |||
| 175 | + | defer debug.SetGCPercent(debug.SetGCPercent(-1)) | |||
| 176 | + | // Like testing.AllocsPerRun: one P, so no other goroutine's runtime | |||
| 177 | + | // bookkeeping (timers, netpoll) lands in the window. | |||
| 178 | + | defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1)) | |||
| 179 | + | runtime.GC() | |||
| 180 | + | runtime.ReadMemStats(&m0) | |||
| 181 | + | drive(target) | |||
| 182 | + | runtime.ReadMemStats(&m1) | |||
| 183 | + | require.Equal(t, target, x.Stats().Events+x.Stats().Replies) | |||
| 184 | + | return m1.Mallocs - m0.Mallocs, hs | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func TestPropertyUnchangedAllocations(t *testing.T) { | |||
| 188 | + | m, hs := replay(t, streamKinds[0]) | |||
| 189 | + | require.Zero(t, m, "unchanged property cycles must not allocate") | |||
| 190 | + | require.Equal(t, "net title", hs.x.tab.rec(hs.x.tab.lookup(streamWin)).title) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestDispatchAllocations(t *testing.T) { | |||
| 194 | + | m, hs := replay(t, streamKinds[1]) | |||
| 195 | + | require.Zero(t, m, "steady-state event dispatch must not allocate") | |||
| 196 | + | require.True(t, hs.x.tab.rec(hs.x.tab.lookup(streamWin)).mapped) | |||
| 197 | + | } | |||
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 +655 -0
| @@ -0,0 +1,655 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | "runtime" | |||
| 8 | + | "sync" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Opcodes the peer understands. | |||
| 17 | + | const ( | |||
| 18 | + | opCreateWindow = 1 | |||
| 19 | + | opChangeWindowAttributes = 2 | |||
| 20 | + | opGetWindowAttributes = 3 | |||
| 21 | + | opMapWindow = 8 | |||
| 22 | + | opDeleteProperty = 19 | |||
| 23 | + | opConfigureWindow = 12 | |||
| 24 | + | opGetGeometry = 14 | |||
| 25 | + | opQueryTree = 15 | |||
| 26 | + | opInternAtom = 16 | |||
| 27 | + | opChangeProperty = 18 | |||
| 28 | + | opGetProperty = 20 | |||
| 29 | + | opSetSelectionOwner = 22 | |||
| 30 | + | opGetSelectionOwner = 23 | |||
| 31 | + | opSendEvent = 25 | |||
| 32 | + | opSetInputFocus = 42 | |||
| 33 | + | opGetInputFocus = 43 | |||
| 34 | + | opQueryExtension = 98 | |||
| 35 | + | opKillClient = 113 | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | const ( | |||
| 39 | + | peerRoot = 0x100 | |||
| 40 | + | peerXIDBase = 0x200000 | |||
| 41 | + | peerXIDMask = 0x1fffff | |||
| 42 | + | peerMaxWords = 0x100000 | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | type xpeerExt struct{ major, firstEvent, firstError uint8 } | |||
| 46 | + | ||||
| 47 | + | // xpeerReq is one request the peer received. | |||
| 48 | + | type xpeerReq struct { | |||
| 49 | + | seq uint16 | |||
| 50 | + | major uint8 | |||
| 51 | + | minor uint8 | |||
| 52 | + | raw []byte // whole request, header included | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r xpeerReq) u32(off int) uint32 { return binary.LittleEndian.Uint32(r.raw[off:]) } | |||
| 56 | + | ||||
| 57 | + | type xpeerProp struct { | |||
| 58 | + | typ uint32 | |||
| 59 | + | format uint8 | |||
| 60 | + | data []byte | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type xpeerGeom struct { | |||
| 64 | + | x, y int16 | |||
| 65 | + | w, h, border uint16 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // xpeer is a scripted X server speaking the real wire protocol over a | |||
| 69 | + | // socketpair: setup, extension queries, atoms, properties, geometry, window | |||
| 70 | + | // tree. It records every request. It is a protocol peer, not a test double of | |||
| 71 | + | // a project interface. | |||
| 72 | + | type xpeer struct { | |||
| 73 | + | t *testing.T | |||
| 74 | + | f *os.File | |||
| 75 | + | ||||
| 76 | + | mu sync.Mutex | |||
| 77 | + | seq uint16 | |||
| 78 | + | reqs []xpeerReq | |||
| 79 | + | exts map[string]xpeerExt | |||
| 80 | + | atoms map[string]uint32 | |||
| 81 | + | nextAtom uint32 | |||
| 82 | + | props map[uint32]map[uint32]xpeerProp | |||
| 83 | + | geoms map[uint32]xpeerGeom | |||
| 84 | + | attrs map[uint32][2]byte // window -> {overrideRedirect, mapState} | |||
| 85 | + | children []uint32 | |||
| 86 | + | selOwner map[uint32]uint32 // GetSelectionOwner answers, by selection atom | |||
| 87 | + | noARGB bool // RENDER has no ARGB32 format | |||
| 88 | + | denyRoot bool // answer the root event-mask request with BadAccess | |||
| 89 | + | pids map[uint32]uint32 // XRes QueryClientIds answers, by window | |||
| 90 | + | bad map[uint32]bool // windows whose GetProperty/GetGeometry fail with BadWindow | |||
| 91 | + | hold bool // keep replies until release | |||
| 92 | + | held [][]byte | |||
| 93 | + | tap []byte // everything written to the client, when recording | |||
| 94 | + | record bool | |||
| 95 | + | ||||
| 96 | + | gate atomic.Pointer[chan struct{}] // closed = reading allowed | |||
| 97 | + | sbuf [4096]byte | |||
| 98 | + | parked, reading atomic.Bool // peer state: at the closed gate / blocked in the header read | |||
| 99 | + | sink atomic.Bool // discard requests without recording or allocating | |||
| 100 | + | // discarded counts requests fully dropped in sink mode. | |||
| 101 | + | discarded atomic.Uint64 | |||
| 102 | + | done chan struct{} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func fullPeerExts() map[string]xpeerExt { | |||
| 106 | + | return map[string]xpeerExt{ | |||
| 107 | + | "BIG-REQUESTS": {133, 0, 0}, | |||
| 108 | + | "XC-MISC": {136, 0, 0}, | |||
| 109 | + | "Composite": {142, 0, 0}, | |||
| 110 | + | "XFIXES": {138, 87, 140}, | |||
| 111 | + | "X-Resource": {144, 0, 0}, | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // startXPeer returns the client end of a socketpair, to give to Boot, and the | |||
| 116 | + | // peer serving the other end. | |||
| 117 | + | func startXPeer(t *testing.T, exts map[string]xpeerExt) (int, *xpeer) { | |||
| 118 | + | t.Helper() | |||
| 119 | + | fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | p := &xpeer{ | |||
| 124 | + | t: t, f: os.NewFile(uintptr(fds[1]), "xpeer"), | |||
| 125 | + | exts: exts, atoms: map[string]uint32{}, nextAtom: 1000, | |||
| 126 | + | props: map[uint32]map[uint32]xpeerProp{}, geoms: map[uint32]xpeerGeom{}, attrs: map[uint32][2]byte{}, | |||
| 127 | + | pids: map[uint32]uint32{}, bad: map[uint32]bool{}, | |||
| 128 | + | done: make(chan struct{}), | |||
| 129 | + | } | |||
| 130 | + | open := make(chan struct{}) | |||
| 131 | + | close(open) | |||
| 132 | + | p.gate.Store(&open) | |||
| 133 | + | t.Cleanup(func() { | |||
| 134 | + | p.resume() // a paused peer would never see the close | |||
| 135 | + | p.f.Close() | |||
| 136 | + | <-p.done | |||
| 137 | + | }) | |||
| 138 | + | go p.serve() | |||
| 139 | + | return fds[0], p | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (p *xpeer) setupReply() []byte { | |||
| 143 | + | vendor := []byte("xpeer\x00\x00\x00") | |||
| 144 | + | b := make([]byte, 40+len(vendor)+40) | |||
| 145 | + | b[0], b[2] = 1, 11 | |||
| 146 | + | binary.LittleEndian.PutUint16(b[6:], uint16((len(b)-8)/4)) | |||
| 147 | + | binary.LittleEndian.PutUint32(b[12:], peerXIDBase) | |||
| 148 | + | binary.LittleEndian.PutUint32(b[16:], peerXIDMask) | |||
| 149 | + | binary.LittleEndian.PutUint16(b[24:], 5) | |||
| 150 | + | b[28] = 1 | |||
| 151 | + | copy(b[40:], vendor) | |||
| 152 | + | scr := b[40+len(vendor):] | |||
| 153 | + | binary.LittleEndian.PutUint32(scr, peerRoot) | |||
| 154 | + | binary.LittleEndian.PutUint32(scr[32:], 0x22) // root visual | |||
| 155 | + | scr[38] = 24 // root depth | |||
| 156 | + | return b | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (p *xpeer) serve() { | |||
| 160 | + | defer close(p.done) | |||
| 161 | + | var hdr [12]byte | |||
| 162 | + | if _, err := io.ReadFull(p.f, hdr[:]); err != nil { | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | // Auth name and data are empty (NewConn sends none). | |||
| 166 | + | if _, err := p.f.Write(p.setupReply()); err != nil { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | for { | |||
| 170 | + | select { | |||
| 171 | + | case <-*p.gate.Load(): | |||
| 172 | + | default: | |||
| 173 | + | p.parked.Store(true) | |||
| 174 | + | <-*p.gate.Load() | |||
| 175 | + | p.parked.Store(false) | |||
| 176 | + | } | |||
| 177 | + | // The sink decision follows the header read, so a request written | |||
| 178 | + | // after sink.Store(true) is always discarded, never recorded. | |||
| 179 | + | p.reading.Store(true) | |||
| 180 | + | _, err := io.ReadFull(p.f, p.sbuf[:4]) | |||
| 181 | + | p.reading.Store(false) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | if p.sink.Load() { | |||
| 186 | + | if !p.discardRest() { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | p.discarded.Add(1) | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | var h [4]byte | |||
| 193 | + | copy(h[:], p.sbuf[:4]) | |||
| 194 | + | raw := h[:] | |||
| 195 | + | n := int(binary.LittleEndian.Uint16(h[2:])) * 4 | |||
| 196 | + | if n == 0 { // BIG-REQUESTS header | |||
| 197 | + | var l [4]byte | |||
| 198 | + | if _, err := io.ReadFull(p.f, l[:]); err != nil { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | raw = append(raw, l[:]...) | |||
| 202 | + | n = int(binary.LittleEndian.Uint32(l[:])) * 4 | |||
| 203 | + | } | |||
| 204 | + | rest := make([]byte, n-len(raw)) | |||
| 205 | + | if _, err := io.ReadFull(p.f, rest); err != nil { | |||
| 206 | + | return | |||
| 207 | + | } | |||
| 208 | + | raw = append(append([]byte(nil), raw...), rest...) | |||
| 209 | + | p.mu.Lock() | |||
| 210 | + | p.seq++ | |||
| 211 | + | r := xpeerReq{p.seq, h[0], h[1], raw} | |||
| 212 | + | p.reqs = append(p.reqs, r) | |||
| 213 | + | p.answer(r, raw[len(raw)-len(rest):]) | |||
| 214 | + | p.mu.Unlock() | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // discardRest drops the rest of the request whose 4-byte header is in | |||
| 219 | + | // p.sbuf, without allocating. It reports false when the connection ended. | |||
| 220 | + | func (p *xpeer) discardRest() bool { | |||
| 221 | + | n := int(binary.LittleEndian.Uint16(p.sbuf[2:])) * 4 | |||
| 222 | + | left := n - 4 | |||
| 223 | + | if n == 0 { | |||
| 224 | + | if _, err := io.ReadFull(p.f, p.sbuf[:4]); err != nil { | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 227 | + | left = int(binary.LittleEndian.Uint32(p.sbuf[:4]))*4 - 8 | |||
| 228 | + | } | |||
| 229 | + | for left > 0 { | |||
| 230 | + | m, err := p.f.Read(p.sbuf[:min(left, len(p.sbuf))]) | |||
| 231 | + | if err != nil { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | left -= m | |||
| 235 | + | } | |||
| 236 | + | return true | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // write sends bytes to the client. The caller holds p.mu. | |||
| 240 | + | func (p *xpeer) write(b []byte) { | |||
| 241 | + | if p.record { | |||
| 242 | + | p.tap = append(p.tap, b...) | |||
| 243 | + | } | |||
| 244 | + | _, _ = p.f.Write(b) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (p *xpeer) reply(data uint8, body []byte) { | |||
| 248 | + | words := (max(len(body), 24) - 24 + 3) / 4 | |||
| 249 | + | b := make([]byte, 32+4*words) | |||
| 250 | + | b[0], b[1] = 1, data | |||
| 251 | + | binary.LittleEndian.PutUint16(b[2:], p.seq) | |||
| 252 | + | binary.LittleEndian.PutUint32(b[4:], uint32(words)) | |||
| 253 | + | copy(b[8:], body) | |||
| 254 | + | if p.hold { | |||
| 255 | + | p.held = append(p.held, b) | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | p.write(b) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // release sends the held replies, in request order as a real server does. | |||
| 262 | + | func (p *xpeer) release() { | |||
| 263 | + | p.mu.Lock() | |||
| 264 | + | defer p.mu.Unlock() | |||
| 265 | + | p.hold = false | |||
| 266 | + | for _, b := range p.held { | |||
| 267 | + | p.write(b) | |||
| 268 | + | } | |||
| 269 | + | p.held = nil | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // holdReplies keeps the replies of the following requests back. | |||
| 273 | + | func (p *xpeer) holdReplies() { | |||
| 274 | + | p.mu.Lock() | |||
| 275 | + | p.hold = true | |||
| 276 | + | p.mu.Unlock() | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func (p *xpeer) errorPacket(code, major uint8, bad uint32) { | |||
| 280 | + | b := make([]byte, 32) | |||
| 281 | + | b[1] = code | |||
| 282 | + | binary.LittleEndian.PutUint16(b[2:], p.seq) | |||
| 283 | + | binary.LittleEndian.PutUint32(b[4:], bad) | |||
| 284 | + | b[10] = major | |||
| 285 | + | p.write(b) | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (p *xpeer) extName(major uint8) string { | |||
| 289 | + | for name, e := range p.exts { | |||
| 290 | + | if e.major == major { | |||
| 291 | + | return name | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | return "" | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func le32(v ...uint32) []byte { | |||
| 298 | + | b := make([]byte, 0, 4*len(v)) | |||
| 299 | + | for _, x := range v { | |||
| 300 | + | b = binary.LittleEndian.AppendUint32(b, x) | |||
| 301 | + | } | |||
| 302 | + | return b | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // answer runs with p.mu held. | |||
| 306 | + | func (p *xpeer) answer(r xpeerReq, body []byte) { | |||
| 307 | + | switch { | |||
| 308 | + | case r.major == opQueryExtension: | |||
| 309 | + | e, ok := p.exts[string(body[4:4+binary.LittleEndian.Uint16(body)])] | |||
| 310 | + | var present uint8 | |||
| 311 | + | if ok { | |||
| 312 | + | present = 1 | |||
| 313 | + | } | |||
| 314 | + | p.reply(0, []byte{present, e.major, e.firstEvent, e.firstError}) | |||
| 315 | + | case r.major == opInternAtom: | |||
| 316 | + | name := string(body[4 : 4+binary.LittleEndian.Uint16(body)]) | |||
| 317 | + | a, ok := p.atoms[name] | |||
| 318 | + | if !ok { | |||
| 319 | + | p.nextAtom++ | |||
| 320 | + | a = p.nextAtom | |||
| 321 | + | p.atoms[name] = a | |||
| 322 | + | } | |||
| 323 | + | p.reply(0, le32(a)) | |||
| 324 | + | case r.major == opGetSelectionOwner: | |||
| 325 | + | p.reply(0, le32(p.selOwner[binary.LittleEndian.Uint32(body)])) | |||
| 326 | + | case r.major == opGetInputFocus: | |||
| 327 | + | p.reply(1, le32(0)) | |||
| 328 | + | case r.major == opQueryTree: | |||
| 329 | + | b := make([]byte, 24+4*len(p.children)) | |||
| 330 | + | binary.LittleEndian.PutUint32(b[0:], peerRoot) | |||
| 331 | + | binary.LittleEndian.PutUint16(b[8:], uint16(len(p.children))) | |||
| 332 | + | for i, c := range p.children { | |||
| 333 | + | binary.LittleEndian.PutUint32(b[24+4*i:], c) | |||
| 334 | + | } | |||
| 335 | + | p.reply(0, b) | |||
| 336 | + | case r.major == opGetWindowAttributes: | |||
| 337 | + | a := p.attrs[binary.LittleEndian.Uint32(body)] | |||
| 338 | + | b := make([]byte, 36) // reply length 3 | |||
| 339 | + | b[18] = a[1] // map_state is byte 26 of the reply | |||
| 340 | + | b[19] = a[0] // override_redirect, byte 27 | |||
| 341 | + | p.reply(0, b) | |||
| 342 | + | case r.major == opGetGeometry && p.bad[binary.LittleEndian.Uint32(body)]: | |||
| 343 | + | p.errorPacket(9, r.major, binary.LittleEndian.Uint32(body)) // BadDrawable | |||
| 344 | + | case r.major == opGetProperty && p.bad[binary.LittleEndian.Uint32(body)]: | |||
| 345 | + | p.errorPacket(3, r.major, binary.LittleEndian.Uint32(body)) // BadWindow | |||
| 346 | + | case r.major == opConfigureWindow: | |||
| 347 | + | g := p.geoms[binary.LittleEndian.Uint32(body)] | |||
| 348 | + | mask, vals := binary.LittleEndian.Uint16(body[4:]), body[8:] | |||
| 349 | + | for bit, dst := 0, []any{&g.x, &g.y, &g.w, &g.h, &g.border}; bit < 5; bit++ { | |||
| 350 | + | if mask&(1<<bit) == 0 { | |||
| 351 | + | continue | |||
| 352 | + | } | |||
| 353 | + | v := uint16(binary.LittleEndian.Uint32(vals)) | |||
| 354 | + | vals = vals[4:] | |||
| 355 | + | switch d := dst[bit].(type) { | |||
| 356 | + | case *int16: | |||
| 357 | + | *d = int16(v) | |||
| 358 | + | case *uint16: | |||
| 359 | + | *d = v | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | p.geoms[binary.LittleEndian.Uint32(body)] = g | |||
| 363 | + | case r.major == opGetGeometry: | |||
| 364 | + | g := p.geoms[binary.LittleEndian.Uint32(body)] | |||
| 365 | + | b := make([]byte, 24) | |||
| 366 | + | binary.LittleEndian.PutUint32(b[0:], peerRoot) | |||
| 367 | + | binary.LittleEndian.PutUint16(b[4:], uint16(g.x)) | |||
| 368 | + | binary.LittleEndian.PutUint16(b[6:], uint16(g.y)) | |||
| 369 | + | binary.LittleEndian.PutUint16(b[8:], g.w) | |||
| 370 | + | binary.LittleEndian.PutUint16(b[10:], g.h) | |||
| 371 | + | binary.LittleEndian.PutUint16(b[12:], g.border) | |||
| 372 | + | p.reply(24, b) | |||
| 373 | + | case r.major == opChangeProperty: | |||
| 374 | + | win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:]) | |||
| 375 | + | format, n := body[12], int(binary.LittleEndian.Uint32(body[16:])) | |||
| 376 | + | if p.props[win] == nil { | |||
| 377 | + | p.props[win] = map[uint32]xpeerProp{} | |||
| 378 | + | } | |||
| 379 | + | p.props[win][prop] = xpeerProp{ | |||
| 380 | + | typ: binary.LittleEndian.Uint32(body[8:]), format: format, | |||
| 381 | + | data: append([]byte(nil), body[20:20+n*int(format)/8]...), | |||
| 382 | + | } | |||
| 383 | + | case r.major == opDeleteProperty: | |||
| 384 | + | delete(p.props[binary.LittleEndian.Uint32(body)], binary.LittleEndian.Uint32(body[4:])) | |||
| 385 | + | case r.major == opGetProperty: | |||
| 386 | + | win, prop := binary.LittleEndian.Uint32(body), binary.LittleEndian.Uint32(body[4:]) | |||
| 387 | + | off, length := int(binary.LittleEndian.Uint32(body[12:]))*4, int(binary.LittleEndian.Uint32(body[16:]))*4 | |||
| 388 | + | v, ok := p.props[win][prop] | |||
| 389 | + | data, after := v.data, 0 | |||
| 390 | + | if off > len(data) { | |||
| 391 | + | off = len(data) | |||
| 392 | + | } | |||
| 393 | + | data = data[off:] | |||
| 394 | + | if len(data) > length { | |||
| 395 | + | data, after = data[:length], len(data)-length | |||
| 396 | + | } | |||
| 397 | + | b := make([]byte, 24+(len(data)+3)&^3) | |||
| 398 | + | if ok && v.format != 0 { | |||
| 399 | + | binary.LittleEndian.PutUint32(b[0:], v.typ) | |||
| 400 | + | binary.LittleEndian.PutUint32(b[4:], uint32(after)) | |||
| 401 | + | binary.LittleEndian.PutUint32(b[8:], uint32(len(data)/(int(v.format)/8))) | |||
| 402 | + | copy(b[24:], data) | |||
| 403 | + | if r.raw[1] != 0 && after == 0 { // delete | |||
| 404 | + | delete(p.props[win], prop) | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | p.reply(v.format, b) | |||
| 408 | + | case r.major == opChangeWindowAttributes: | |||
| 409 | + | const cwEventMask = 1 << 11 | |||
| 410 | + | if p.denyRoot && binary.LittleEndian.Uint32(body) == peerRoot && binary.LittleEndian.Uint32(body[4:])&cwEventMask != 0 { | |||
| 411 | + | p.errorPacket(10, opChangeWindowAttributes, peerRoot) // BadAccess | |||
| 412 | + | } | |||
| 413 | + | case r.major >= 128: | |||
| 414 | + | switch p.extName(r.major) { | |||
| 415 | + | case "BIG-REQUESTS": | |||
| 416 | + | p.reply(0, le32(peerMaxWords)) | |||
| 417 | + | case "XC-MISC": | |||
| 418 | + | if r.minor == 1 { | |||
| 419 | + | p.reply(0, le32(0, 1)) | |||
| 420 | + | } | |||
| 421 | + | case "RENDER": | |||
| 422 | + | if r.minor == 1 { // QueryPictFormats: one format (ARGB32), unless noARGB | |||
| 423 | + | n := uint32(1) | |||
| 424 | + | if p.noARGB { | |||
| 425 | + | n = 0 | |||
| 426 | + | } | |||
| 427 | + | b := make([]byte, 24+28*int(n)) | |||
| 428 | + | binary.LittleEndian.PutUint32(b[0:], n) | |||
| 429 | + | if n == 1 { | |||
| 430 | + | f := b[24:] | |||
| 431 | + | binary.LittleEndian.PutUint32(f[0:], 0x77) // format id | |||
| 432 | + | f[4], f[5] = 1, 32 // direct, depth 32 | |||
| 433 | + | binary.LittleEndian.PutUint16(f[8:], 16) // red shift | |||
| 434 | + | binary.LittleEndian.PutUint16(f[10:], 0xff) | |||
| 435 | + | binary.LittleEndian.PutUint16(f[20:], 24) // alpha shift | |||
| 436 | + | binary.LittleEndian.PutUint16(f[22:], 0xff) | |||
| 437 | + | } | |||
| 438 | + | p.reply(0, b) | |||
| 439 | + | } | |||
| 440 | + | case "Composite", "XFIXES": | |||
| 441 | + | if r.minor == 0 { | |||
| 442 | + | p.reply(0, le32(5, 4)) | |||
| 443 | + | } | |||
| 444 | + | case "X-Resource": | |||
| 445 | + | switch r.minor { | |||
| 446 | + | case 0: | |||
| 447 | + | p.reply(0, []byte{1, 0, 2, 0}) | |||
| 448 | + | case 4: // QueryClientIds: one spec, answered when a pid is scripted | |||
| 449 | + | pid, ok := p.pids[r.u32(8)] | |||
| 450 | + | if !ok { | |||
| 451 | + | p.reply(0, le32(0)) | |||
| 452 | + | break | |||
| 453 | + | } | |||
| 454 | + | b := make([]byte, 40) | |||
| 455 | + | binary.LittleEndian.PutUint32(b[0:], 1) | |||
| 456 | + | binary.LittleEndian.PutUint32(b[24:], r.u32(8)) | |||
| 457 | + | binary.LittleEndian.PutUint32(b[28:], 2) // LocalClientPID | |||
| 458 | + | binary.LittleEndian.PutUint32(b[32:], 4) | |||
| 459 | + | binary.LittleEndian.PutUint32(b[36:], pid) | |||
| 460 | + | p.reply(0, b) | |||
| 461 | + | } | |||
| 462 | + | } | |||
| 463 | + | } | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | // requests returns a snapshot of every request received after setup. | |||
| 467 | + | func (p *xpeer) requests() []xpeerReq { | |||
| 468 | + | p.mu.Lock() | |||
| 469 | + | defer p.mu.Unlock() | |||
| 470 | + | return append([]xpeerReq(nil), p.reqs...) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | // waitReq waits for a request satisfying match among those after the first | |||
| 474 | + | // skip requests, and returns it with the new count. | |||
| 475 | + | func (p *xpeer) waitReq(skip int, match func(xpeerReq) bool) (xpeerReq, int) { | |||
| 476 | + | p.t.Helper() | |||
| 477 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 478 | + | for time.Now().Before(deadline) { | |||
| 479 | + | reqs := p.requests() | |||
| 480 | + | for i := skip; i < len(reqs); i++ { | |||
| 481 | + | if match(reqs[i]) { | |||
| 482 | + | return reqs[i], i + 1 | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | time.Sleep(time.Millisecond) | |||
| 486 | + | } | |||
| 487 | + | p.t.Fatal("xpeer: expected request never arrived") | |||
| 488 | + | return xpeerReq{}, 0 | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func (p *xpeer) atom(name string) uint32 { | |||
| 492 | + | p.mu.Lock() | |||
| 493 | + | defer p.mu.Unlock() | |||
| 494 | + | return p.atoms[name] | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func (p *xpeer) prop(win uint32, atom string) (xpeerProp, bool) { | |||
| 498 | + | p.mu.Lock() | |||
| 499 | + | defer p.mu.Unlock() | |||
| 500 | + | v, ok := p.props[win][p.atoms[atom]] | |||
| 501 | + | return v, ok | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | // event sends a 32-byte core event stamped with the current sequence number. | |||
| 505 | + | func (p *xpeer) event(code uint8, fill func(b *[32]byte)) { | |||
| 506 | + | p.mu.Lock() | |||
| 507 | + | defer p.mu.Unlock() | |||
| 508 | + | var b [32]byte | |||
| 509 | + | b[0] = code | |||
| 510 | + | binary.LittleEndian.PutUint16(b[2:], p.seq) | |||
| 511 | + | if fill != nil { | |||
| 512 | + | fill(&b) | |||
| 513 | + | } | |||
| 514 | + | p.write(b[:]) | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | func (p *xpeer) createNotify(win uint32, override bool) { | |||
| 518 | + | p.event(16, func(b *[32]byte) { | |||
| 519 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 520 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 521 | + | binary.LittleEndian.PutUint16(b[16:], 100) | |||
| 522 | + | binary.LittleEndian.PutUint16(b[18:], 50) | |||
| 523 | + | if override { | |||
| 524 | + | b[22] = 1 | |||
| 525 | + | } | |||
| 526 | + | }) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func (p *xpeer) destroyNotify(win uint32) { | |||
| 530 | + | p.event(17, func(b *[32]byte) { | |||
| 531 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 532 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 533 | + | }) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | // pause closes the gate: the peer parks before reading its next request. | |||
| 537 | + | func (p *xpeer) pause() { | |||
| 538 | + | g := make(chan struct{}) | |||
| 539 | + | p.gate.Store(&g) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 542 | + | // waitParked waits until the peer goroutine is parked at the closed gate. A | |||
| 543 | + | // peer blocked in its header read only gets there after one more request, so | |||
| 544 | + | // the caller must have one in flight (or be sure it is parked already). | |||
| 545 | + | func (p *xpeer) waitParked() { | |||
| 546 | + | p.t.Helper() | |||
| 547 | + | for !p.parked.Load() { | |||
| 548 | + | runtime.Gosched() | |||
| 549 | + | } | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // resume lets the peer read again. | |||
| 553 | + | func (p *xpeer) resume() { | |||
| 554 | + | g := *p.gate.Load() | |||
| 555 | + | open := make(chan struct{}) | |||
| 556 | + | close(open) | |||
| 557 | + | p.gate.Store(&open) | |||
| 558 | + | select { | |||
| 559 | + | case <-g: | |||
| 560 | + | default: | |||
| 561 | + | close(g) | |||
| 562 | + | } | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | // set mutates the peer's script under its lock. | |||
| 566 | + | func (p *xpeer) set(fn func(p *xpeer)) { | |||
| 567 | + | p.mu.Lock() | |||
| 568 | + | defer p.mu.Unlock() | |||
| 569 | + | fn(p) | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // hangup closes the server end; the client reads EOF. | |||
| 573 | + | func (p *xpeer) hangup() { | |||
| 574 | + | if rc, err := p.f.SyscallConn(); err == nil { | |||
| 575 | + | _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) }) | |||
| 576 | + | } | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | // setProp scripts a window property for GetProperty. | |||
| 580 | + | func (p *xpeer) setProp(win, atom, typ uint32, format uint8, data []byte) { | |||
| 581 | + | p.mu.Lock() | |||
| 582 | + | defer p.mu.Unlock() | |||
| 583 | + | if p.props[win] == nil { | |||
| 584 | + | p.props[win] = map[uint32]xpeerProp{} | |||
| 585 | + | } | |||
| 586 | + | p.props[win][atom] = xpeerProp{typ: typ, format: format, data: data} | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func (p *xpeer) mapRequest(win uint32) { | |||
| 590 | + | p.event(20, func(b *[32]byte) { | |||
| 591 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 592 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 593 | + | }) | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | func (p *xpeer) mapNotify(win uint32) { | |||
| 597 | + | p.event(19, func(b *[32]byte) { | |||
| 598 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 599 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 600 | + | }) | |||
| 601 | + | } | |||
| 602 | + | ||||
| 603 | + | func (p *xpeer) unmapNotify(win uint32, synthetic bool) { | |||
| 604 | + | p.event(18, func(b *[32]byte) { | |||
| 605 | + | if synthetic { | |||
| 606 | + | b[0] |= 0x80 | |||
| 607 | + | } | |||
| 608 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 609 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 610 | + | }) | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func (p *xpeer) propertyNotify(win, atom uint32, deleted bool) { | |||
| 614 | + | p.event(28, func(b *[32]byte) { | |||
| 615 | + | binary.LittleEndian.PutUint32(b[4:], win) | |||
| 616 | + | binary.LittleEndian.PutUint32(b[8:], atom) | |||
| 617 | + | if deleted { | |||
| 618 | + | b[16] = 1 | |||
| 619 | + | } | |||
| 620 | + | }) | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | func (p *xpeer) reparentNotify(win, parent uint32) { | |||
| 624 | + | p.event(21, func(b *[32]byte) { | |||
| 625 | + | binary.LittleEndian.PutUint32(b[4:], peerRoot) | |||
| 626 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 627 | + | binary.LittleEndian.PutUint32(b[12:], parent) | |||
| 628 | + | }) | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | func (p *xpeer) clientMessage(win, typ uint32, data ...uint32) { | |||
| 632 | + | p.event(33, func(b *[32]byte) { | |||
| 633 | + | b[1] = 32 | |||
| 634 | + | binary.LittleEndian.PutUint32(b[4:], win) | |||
| 635 | + | binary.LittleEndian.PutUint32(b[8:], typ) | |||
| 636 | + | for i, d := range data { | |||
| 637 | + | binary.LittleEndian.PutUint32(b[12+4*i:], d) | |||
| 638 | + | } | |||
| 639 | + | }) | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | func (p *xpeer) focusIn(win uint32) { | |||
| 643 | + | p.event(9, func(b *[32]byte) { binary.LittleEndian.PutUint32(b[4:], win) }) | |||
| 644 | + | } | |||
| 645 | + | ||||
| 646 | + | func (p *xpeer) configureNotify(win uint32) { | |||
| 647 | + | p.event(22, func(b *[32]byte) { | |||
| 648 | + | binary.LittleEndian.PutUint32(b[4:], win) | |||
| 649 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 650 | + | binary.LittleEndian.PutUint16(b[20:], 10) | |||
| 651 | + | binary.LittleEndian.PutUint16(b[22:], 20) | |||
| 652 | + | binary.LittleEndian.PutUint16(b[24:], 300) | |||
| 653 | + | binary.LittleEndian.PutUint16(b[26:], 200) | |||
| 654 | + | }) | |||
| 655 | + | } | |||
A internal/adapters/wayland/xwm/xsettings_test.go +244 -0
| @@ -0,0 +1,244 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func bootSettings(t *testing.T, cfg ports.XwaylandSettings, mutate func(p *xpeer)) (*XWM, *xpeer) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | fd, p := startXPeer(t, fullPeerExts()) | |||
| 17 | + | if mutate != nil { | |||
| 18 | + | p.set(mutate) | |||
| 19 | + | } | |||
| 20 | + | x, err := BootSettings(context.Background(), fd, testLog(), cfg) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | t.Cleanup(x.CloseConn) | |||
| 23 | + | return x, p | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (p *xpeer) count(major uint8, match func(xpeerReq) bool) (n int) { | |||
| 27 | + | for _, r := range p.requests() { | |||
| 28 | + | if r.major == major && (match == nil || match(r)) { | |||
| 29 | + | n++ | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | return n | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestXSettingsEncode(t *testing.T) { | |||
| 36 | + | s := &xsettings{serial: 7, theme: "ab", size: 24, themeSerial: 7, sizeSerial: 5, fixedSerial: 3} | |||
| 37 | + | n := s.encode() | |||
| 38 | + | u32 := binary.LittleEndian.AppendUint32 | |||
| 39 | + | var want []byte | |||
| 40 | + | want = append(want, 0, 0, 0, 0) | |||
| 41 | + | want = u32(want, 7) | |||
| 42 | + | want = u32(want, 5) | |||
| 43 | + | // Gdk/UnscaledDPI INT (name length 15 -> pad 16) | |||
| 44 | + | want = append(want, 0, 0, 15, 0) | |||
| 45 | + | want = append(want, "Gdk/UnscaledDPI"...) | |||
| 46 | + | want = append(want, 0) | |||
| 47 | + | want = u32(want, 3) | |||
| 48 | + | want = u32(want, 96*1024) | |||
| 49 | + | // Gdk/WindowScalingFactor INT 1 (length 23 -> pad 24) | |||
| 50 | + | want = append(want, 0, 0, 23, 0) | |||
| 51 | + | want = append(want, "Gdk/WindowScalingFactor"...) | |||
| 52 | + | want = append(want, 0) | |||
| 53 | + | want = u32(want, 3) | |||
| 54 | + | want = u32(want, 1) | |||
| 55 | + | // Gtk/CursorThemeName STRING "ab" | |||
| 56 | + | want = append(want, 1, 0, 19, 0) | |||
| 57 | + | want = append(want, "Gtk/CursorThemeName"...) | |||
| 58 | + | want = append(want, 0) // 19 -> 20 | |||
| 59 | + | want = u32(want, 7) | |||
| 60 | + | want = u32(want, 2) | |||
| 61 | + | want = append(want, 'a', 'b', 0, 0) | |||
| 62 | + | // Gtk/CursorThemeSize INT 24 | |||
| 63 | + | want = append(want, 0, 0, 19, 0) | |||
| 64 | + | want = append(want, "Gtk/CursorThemeSize"...) | |||
| 65 | + | want = append(want, 0) | |||
| 66 | + | want = u32(want, 5) | |||
| 67 | + | want = u32(want, 24) | |||
| 68 | + | // Xft/DPI INT (name length 7 -> pad 1) | |||
| 69 | + | want = append(want, 0, 0, 7, 0) | |||
| 70 | + | want = append(want, "Xft/DPI"...) | |||
| 71 | + | want = append(want, 0) | |||
| 72 | + | want = u32(want, 3) | |||
| 73 | + | want = u32(want, 96*1024) | |||
| 74 | + | require.Equal(t, want, s.buf[:n]) | |||
| 75 | + | // Name lengths 1..4 mod 4 pad to a multiple of 4. | |||
| 76 | + | for l := 1; l <= 8; l++ { | |||
| 77 | + | require.Zero(t, pad4(l)%4) | |||
| 78 | + | require.GreaterOrEqual(t, pad4(l), l) | |||
| 79 | + | require.Less(t, pad4(l)-l, 4) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestXSettingsOwnership(t *testing.T) { | |||
| 84 | + | x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 32}, nil) | |||
| 85 | + | win := x.xs.win | |||
| 86 | + | require.NotZero(t, win) | |||
| 87 | + | s0, mgr := p.atom("_XSETTINGS_S0"), p.atom("MANAGER") | |||
| 88 | + | own := -1 | |||
| 89 | + | for i, r := range p.requests() { | |||
| 90 | + | if r.major == opSetSelectionOwner && r.u32(8) == s0 { | |||
| 91 | + | own = i | |||
| 92 | + | require.Equal(t, win, r.u32(4)) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | require.NotEqual(t, -1, own) | |||
| 96 | + | var sent bool | |||
| 97 | + | for _, r := range p.requests() { | |||
| 98 | + | if r.major == opSendEvent && r.u32(4) == peerRoot && r.u32(20) == mgr { | |||
| 99 | + | sent = true | |||
| 100 | + | require.Equal(t, s0, r.u32(28)) | |||
| 101 | + | require.Equal(t, win, r.u32(32)) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | require.True(t, sent, "MANAGER client message to the root") | |||
| 105 | + | prop, ok := p.prop(win, "_XSETTINGS_SETTINGS") | |||
| 106 | + | require.True(t, ok) | |||
| 107 | + | require.Equal(t, p.atom("_XSETTINGS_SETTINGS"), prop.typ) | |||
| 108 | + | require.EqualValues(t, 8, prop.format) | |||
| 109 | + | ||||
| 110 | + | // SelectionClear stops updates. | |||
| 111 | + | require.True(t, x.xs.active) | |||
| 112 | + | p.event(29, func(b *[32]byte) { | |||
| 113 | + | binary.LittleEndian.PutUint32(b[8:], win) | |||
| 114 | + | binary.LittleEndian.PutUint32(b[12:], s0) | |||
| 115 | + | }) | |||
| 116 | + | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 117 | + | require.False(t, x.xs.active) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestXSettingsExistingOwner(t *testing.T) { | |||
| 121 | + | var s0 uint32 | |||
| 122 | + | x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true}, func(p *xpeer) { | |||
| 123 | + | p.selOwner = map[uint32]uint32{} | |||
| 124 | + | // atoms are assigned on intern: pre-assign through a known name. | |||
| 125 | + | p.nextAtom++ | |||
| 126 | + | s0 = p.nextAtom | |||
| 127 | + | p.atoms["_XSETTINGS_S0"] = s0 | |||
| 128 | + | p.selOwner[s0] = 0xabc | |||
| 129 | + | }) | |||
| 130 | + | require.False(t, x.xs.active) | |||
| 131 | + | require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 })) | |||
| 132 | + | _, ok := p.prop(x.xs.win, "_XSETTINGS_SETTINGS") | |||
| 133 | + | require.False(t, ok) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestXSettingsOff(t *testing.T) { | |||
| 137 | + | _, p := bootSettings(t, ports.XwaylandSettings{XSettings: false, CursorSize: 24}, nil) | |||
| 138 | + | s0 := p.atom("_XSETTINGS_S0") | |||
| 139 | + | require.Zero(t, p.count(opSetSelectionOwner, func(r xpeerReq) bool { return r.u32(8) == s0 && s0 != 0 })) | |||
| 140 | + | require.Zero(t, p.count(opGetSelectionOwner, nil)) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // decodeSettings returns serial and the per-setting last-change serial by name. | |||
| 144 | + | func decodeSettings(t *testing.T, b []byte) (uint32, map[string]uint32) { | |||
| 145 | + | t.Helper() | |||
| 146 | + | le := binary.LittleEndian | |||
| 147 | + | out := map[string]uint32{} | |||
| 148 | + | n := int(le.Uint32(b[8:])) | |||
| 149 | + | off := 12 | |||
| 150 | + | for i := 0; i < n; i++ { | |||
| 151 | + | typ, nl := b[off], int(le.Uint16(b[off+2:])) | |||
| 152 | + | off += 4 | |||
| 153 | + | name := string(b[off : off+nl]) | |||
| 154 | + | off += pad4(nl) | |||
| 155 | + | out[name] = le.Uint32(b[off:]) | |||
| 156 | + | off += 4 | |||
| 157 | + | if typ == xsString { | |||
| 158 | + | off += 4 + pad4(int(le.Uint32(b[off:]))) | |||
| 159 | + | } else { | |||
| 160 | + | off += 4 | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | return le.Uint32(b[4:]), out | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestXSettingsSerial(t *testing.T) { | |||
| 167 | + | x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorSize: 24}, nil) | |||
| 168 | + | win := x.xs.win | |||
| 169 | + | props := func() int { | |||
| 170 | + | return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win }) | |||
| 171 | + | } | |||
| 172 | + | require.Equal(t, 1, props()) | |||
| 173 | + | // Layout changes (any scale) and identical settings write nothing. | |||
| 174 | + | for _, scale := range []float64{1, 2} { | |||
| 175 | + | var sp Space | |||
| 176 | + | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}}) | |||
| 177 | + | x.space = &sp | |||
| 178 | + | x.LayoutChanged(true) | |||
| 179 | + | require.NoError(t, x.conn.Flush()) | |||
| 180 | + | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 181 | + | require.Equal(t, 1, props(), "scale %v", scale) | |||
| 182 | + | } | |||
| 183 | + | prop, _ := p.prop(win, "_XSETTINGS_SETTINGS") | |||
| 184 | + | serial, last := decodeSettings(t, prop.data) | |||
| 185 | + | require.EqualValues(t, 1, serial) | |||
| 186 | + | require.EqualValues(t, 1, last["Xft/DPI"]) | |||
| 187 | + | require.EqualValues(t, 1, last["Gdk/WindowScalingFactor"]) | |||
| 188 | + | require.EqualValues(t, 1, last["Gdk/UnscaledDPI"]) | |||
| 189 | + | require.EqualValues(t, 96*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:])), "Xft/DPI is last") | |||
| 190 | + | ||||
| 191 | + | // A changed cursor size republishes: only that setting's serial moves. | |||
| 192 | + | x.cfg.CursorSize = 32 | |||
| 193 | + | require.True(t, x.publishXSettings(false)) | |||
| 194 | + | require.NoError(t, x.conn.Flush()) | |||
| 195 | + | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 196 | + | prop, _ = p.prop(win, "_XSETTINGS_SETTINGS") | |||
| 197 | + | serial, last = decodeSettings(t, prop.data) | |||
| 198 | + | require.EqualValues(t, 2, serial) | |||
| 199 | + | require.EqualValues(t, 2, last["Gtk/CursorThemeSize"]) | |||
| 200 | + | require.EqualValues(t, 1, last["Xft/DPI"]) | |||
| 201 | + | require.False(t, x.publishXSettings(false), "identical settings write nothing") | |||
| 202 | + | ||||
| 203 | + | x.xs.serial = 0xffffffff | |||
| 204 | + | x.xs.size = 0 | |||
| 205 | + | x.publishXSettings(false) | |||
| 206 | + | require.EqualValues(t, 0, x.xs.serial, "wraps") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestXSettingsNoChangeAllocations(t *testing.T) { | |||
| 210 | + | x, _ := bootSettings(t, ports.XwaylandSettings{XSettings: true, CursorTheme: "Adw", CursorSize: 24}, nil) | |||
| 211 | + | var sp Space | |||
| 212 | + | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}}) | |||
| 213 | + | x.space = &sp | |||
| 214 | + | x.LayoutChanged(true) | |||
| 215 | + | l := ports.Layout{{Width: 100, Height: 100, Scale: 2}} | |||
| 216 | + | n := testing.AllocsPerRun(100, func() { | |||
| 217 | + | sp.Set(l) | |||
| 218 | + | x.LayoutChanged(false) | |||
| 219 | + | x.publishXSettings(false) | |||
| 220 | + | }) | |||
| 221 | + | require.Zero(t, n) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // The XSETTINGS manager window is never adopted as a client window. | |||
| 225 | + | func TestXSettingsWindowNotAdopted(t *testing.T) { | |||
| 226 | + | x, p := bootSettings(t, ports.XwaylandSettings{XSettings: true}, nil) | |||
| 227 | + | h := newMockhost(t) | |||
| 228 | + | post, _ := newPost() | |||
| 229 | + | x.attach(newMockfdSource(t), h, post) | |||
| 230 | + | require.NotZero(t, x.xs.win) | |||
| 231 | + | p.createNotify(x.xs.win, false) | |||
| 232 | + | ev := x.Stats().Events | |||
| 233 | + | readUntil(t, x, func() bool { return x.Stats().Events > ev }) | |||
| 234 | + | x.flush() | |||
| 235 | + | require.Zero(t, x.tab.lookup(x.xs.win)) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func TestCursorThemeNameRuneBoundary(t *testing.T) { | |||
| 239 | + | x := &XWM{} | |||
| 240 | + | x.cfg.CursorTheme = strings.Repeat("a", xsMaxString-1) + "é" // é straddles the limit | |||
| 241 | + | got := x.cursorThemeName() | |||
| 242 | + | require.True(t, utf8.ValidString(got)) | |||
| 243 | + | require.Equal(t, strings.Repeat("a", xsMaxString-1), got) | |||
| 244 | + | } | |||
A internal/adapters/wayland/xwm/xwm.go +509 -0
| @@ -0,0 +1,509 @@ | |||||
| 1 | + | // Package xwm is the built-in X11 window manager for Xwayland: it speaks the | |||
| 2 | + | // X protocol over the -wm socket with xturbo and reports managed windows to | |||
| 3 | + | // the Wayland server through the unexported host interface. | |||
| 4 | + | // | |||
| 5 | + | // Threading: Boot runs on the starter goroutine and blocks. The XWM it returns | |||
| 6 | + | // is then handed to the display goroutine, which owns it exclusively; nothing | |||
| 7 | + | // in this package is locked. | |||
| 8 | + | package xwm | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "context" | |||
| 12 | + | "encoding/binary" | |||
| 13 | + | "errors" | |||
| 14 | + | "fmt" | |||
| 15 | + | "io" | |||
| 16 | + | "os" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/xturbo" | |||
| 20 | + | "bnema.dev/xturbo/composite" | |||
| 21 | + | "bnema.dev/xturbo/res" | |||
| 22 | + | "bnema.dev/xturbo/xfixes" | |||
| 23 | + | "bnema.dev/xturbo/xproto" | |||
| 24 | + | "github.com/bnema/purego-libwayland/server" | |||
| 25 | + | "github.com/bnema/zerowrap" | |||
| 26 | + | "golang.org/x/sys/unix" | |||
| 27 | + | ||||
| 28 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | var ( | |||
| 32 | + | // ErrWMExists is returned by Boot when another client already redirects | |||
| 33 | + | // the root window's children. | |||
| 34 | + | ErrWMExists = errors.New("xwm: another window manager owns the root window") | |||
| 35 | + | // ErrNoComposite is returned by Boot when the X server lacks Composite. | |||
| 36 | + | ErrNoComposite = errors.New("xwm: X server has no Composite extension") | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // Stats are connection and event counters, for diagnostics. | |||
| 40 | + | type Stats struct { | |||
| 41 | + | Events, Replies, Errors, Unattributed, Stale, RingFull, LegacyID, UnmanagedSeen uint64 | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Version request ids, carried in the first Expect argument of kindVersion. | |||
| 45 | + | const ( | |||
| 46 | + | versionComposite uint32 = iota + 1 | |||
| 47 | + | versionXFixes | |||
| 48 | + | versionRes | |||
| 49 | + | ) | |||
| 50 | + | ||||
| 51 | + | const unpairedCap = 64 | |||
| 52 | + | ||||
| 53 | + | // XWM manages the windows of one Xwayland. Display goroutine only, after Boot. | |||
| 54 | + | type XWM struct { | |||
| 55 | + | conn *xturbo.Conn | |||
| 56 | + | log zerowrap.Logger | |||
| 57 | + | root uint32 | |||
| 58 | + | wmWin uint32 | |||
| 59 | + | selWin uint32 // selection bridge window (selection.go) | |||
| 60 | + | lastTime uint32 // server time of the latest timestamped X event | |||
| 61 | + | sel selBridge | |||
| 62 | + | atoms [atomCount]uint32 | |||
| 63 | + | tab *table | |||
| 64 | + | h xturbo.Handler | |||
| 65 | + | stats Stats | |||
| 66 | + | ||||
| 67 | + | // attachment | |||
| 68 | + | host host | |||
| 69 | + | src fdSource | |||
| 70 | + | lp loop // display fd sources and timers | |||
| 71 | + | post func(func()) | |||
| 72 | + | flushFn func() | |||
| 73 | + | ||||
| 74 | + | posted, writable, closed bool | |||
| 75 | + | bootErr error | |||
| 76 | + | warnedRing, warnedFull bool | |||
| 77 | + | warnedUnpaired bool | |||
| 78 | + | ||||
| 79 | + | scratch []byte // encode buffer for 32-bit property values, 4 bytes per slot | |||
| 80 | + | ||||
| 81 | + | // Shared by the map, configure and focus flows. | |||
| 82 | + | dirty []Slot // slots with a pending configure, capacity == table size | |||
| 83 | + | clients []uint32 // _NET_CLIENT_LIST, map order | |||
| 84 | + | stacking []uint32 // _NET_CLIENT_LIST_STACKING, bottom to top | |||
| 85 | + | active Slot // slot named by _NET_ACTIVE_WINDOW, 0 = none | |||
| 86 | + | refocus bool // a FocusIn was denied; focus is re-issued in flush | |||
| 87 | + | listsDirty uint8 // client lists to rewrite in flush | |||
| 88 | + | unpaired [unpairedCap]struct{ serial, surface uint64 } | |||
| 89 | + | unpairedN int | |||
| 90 | + | ||||
| 91 | + | cfg ports.XwaylandSettings | |||
| 92 | + | xs xsettings // XSETTINGS manager and root cursor (xsettings.go, cursor.go) | |||
| 93 | + | ||||
| 94 | + | space *Space // layout <-> X coordinates, shared with the host | |||
| 95 | + | warnedSpace bool // more outputs than the Space holds, logged once | |||
| 96 | + | ovDirty []Slot // mapped unmanaged slots whose geometry changed, capacity == table size | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func newXWM(conn *xturbo.Conn, log zerowrap.Logger) *XWM { | |||
| 100 | + | x := &XWM{conn: conn, log: log, root: conn.Root(), tab: newTable(initialSlots), space: new(Space)} | |||
| 101 | + | x.h = xturbo.Handler{OnEvent: x.onEvent, OnReply: x.onReply, OnError: x.onError} | |||
| 102 | + | x.fit() | |||
| 103 | + | x.initSelection() | |||
| 104 | + | return x | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // fit sizes the per-slot buffers to the table; called after every insert. | |||
| 108 | + | func (x *XWM) fit() { | |||
| 109 | + | n := len(x.tab.recs) | |||
| 110 | + | if cap(x.dirty) < n { | |||
| 111 | + | x.dirty = append(make([]Slot, 0, n), x.dirty...) | |||
| 112 | + | x.clients = append(make([]uint32, 0, n), x.clients...) | |||
| 113 | + | x.ovDirty = append(make([]Slot, 0, n), x.ovDirty...) | |||
| 114 | + | x.stacking = append(make([]uint32, 0, n), x.stacking...) | |||
| 115 | + | x.scratch = make([]byte, 0, 4*n) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // insert adds a record for xid; 0 means the table is full. | |||
| 120 | + | func (x *XWM) insert(xid uint32) Slot { | |||
| 121 | + | s := x.tab.insert(xid) | |||
| 122 | + | if s == 0 { | |||
| 123 | + | if !x.warnedFull { | |||
| 124 | + | x.warnedFull = true | |||
| 125 | + | x.log.Warn().Int("windows", x.tab.n).Msg("X11 window table full, window ignored") | |||
| 126 | + | } | |||
| 127 | + | return 0 | |||
| 128 | + | } | |||
| 129 | + | x.fit() | |||
| 130 | + | return s | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // expect registers seq, tolerating a full ring: the reply then arrives | |||
| 134 | + | // unattributed and is dropped. | |||
| 135 | + | func (x *XWM) expect(seq xturbo.Seq, kind uint16, a0, a1 uint32) { | |||
| 136 | + | if seq == 0 { | |||
| 137 | + | return | |||
| 138 | + | } | |||
| 139 | + | if err := x.conn.Expect(seq, kind, a0, a1, 0, 0); err != nil { | |||
| 140 | + | x.stats.RingFull++ | |||
| 141 | + | if !x.warnedRing { | |||
| 142 | + | x.warnedRing = true | |||
| 143 | + | x.log.Warn().Err(err).Msg("X11 request not tracked") | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // setProp32 queues ChangeProperty (replace, format 32) for vals. | |||
| 149 | + | func (x *XWM) setProp32(win uint32, prop int, typ xproto.Atom, vals []uint32) { | |||
| 150 | + | n := 4 * len(vals) | |||
| 151 | + | if cap(x.scratch) < n { | |||
| 152 | + | x.scratch = make([]byte, 0, n) | |||
| 153 | + | } | |||
| 154 | + | p := x.scratch[:n] | |||
| 155 | + | for i, v := range vals { | |||
| 156 | + | binary.LittleEndian.PutUint32(p[4*i:], v) | |||
| 157 | + | } | |||
| 158 | + | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(win), | |||
| 159 | + | xproto.Atom(x.atoms[prop]), typ, 32, uint32(len(vals)), p) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Boot connects to the X server on wmFD and becomes its window manager. It | |||
| 163 | + | // blocks (about 25 s at worst) and must not run on the display goroutine: the | |||
| 164 | + | // handshake needs Xwayland, which needs the display goroutine to serve its | |||
| 165 | + | // Wayland requests. It takes ownership of wmFD and closes it on failure. | |||
| 166 | + | // Boot gives up promptly when ctx ends (the Xwayland process died or the run | |||
| 167 | + | // was cancelled). log is scoped by the caller with logging.For(ctx, "xwm"). | |||
| 168 | + | func Boot(ctx context.Context, wmFD int, log zerowrap.Logger) (*XWM, error) { | |||
| 169 | + | return BootSettings(ctx, wmFD, log, ports.XwaylandSettings{}) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // BootSettings is Boot with the XSETTINGS manager and the root cursor of cfg. | |||
| 173 | + | func BootSettings(ctx context.Context, wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) { | |||
| 174 | + | // The watcher shuts the socket down when ctx ends, which wakes any blocked | |||
| 175 | + | // handshake, Sync or InternAtoms. It holds its own duplicate so a shutdown | |||
| 176 | + | // can never reach a descriptor number reused after Boot closed wmFD. | |||
| 177 | + | var watch *os.File | |||
| 178 | + | if dup, err := unix.FcntlInt(uintptr(wmFD), unix.F_DUPFD_CLOEXEC, 0); err == nil { | |||
| 179 | + | watch = os.NewFile(uintptr(dup), "xwm abort") | |||
| 180 | + | } | |||
| 181 | + | stopWatch := func() {} | |||
| 182 | + | if watch != nil { | |||
| 183 | + | quit, joined := make(chan struct{}), make(chan struct{}) | |||
| 184 | + | go func() { | |||
| 185 | + | defer close(joined) | |||
| 186 | + | select { | |||
| 187 | + | case <-ctx.Done(): | |||
| 188 | + | if rc, err := watch.SyscallConn(); err == nil { | |||
| 189 | + | _ = rc.Control(func(fd uintptr) { _ = unix.Shutdown(int(fd), unix.SHUT_RDWR) }) | |||
| 190 | + | } | |||
| 191 | + | case <-quit: | |||
| 192 | + | } | |||
| 193 | + | _ = watch.Close() | |||
| 194 | + | }() | |||
| 195 | + | stopWatch = func() { close(quit); <-joined } | |||
| 196 | + | } | |||
| 197 | + | x, err := boot(wmFD, log, cfg) | |||
| 198 | + | stopWatch() | |||
| 199 | + | if err == nil && ctx.Err() != nil { | |||
| 200 | + | x.CloseConn() | |||
| 201 | + | x, err = nil, ctx.Err() | |||
| 202 | + | } | |||
| 203 | + | if err != nil && ctx.Err() != nil { | |||
| 204 | + | err = fmt.Errorf("%w (%w)", err, ctx.Err()) | |||
| 205 | + | } | |||
| 206 | + | return x, err | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func boot(wmFD int, log zerowrap.Logger, cfg ports.XwaylandSettings) (*XWM, error) { | |||
| 210 | + | conn, err := xturbo.NewConn(wmFD) | |||
| 211 | + | if err != nil { | |||
| 212 | + | return nil, fmt.Errorf("xwm: connect: %w", err) | |||
| 213 | + | } | |||
| 214 | + | x := newXWM(conn, log) | |||
| 215 | + | x.cfg = cfg | |||
| 216 | + | if err := x.boot(); err != nil { | |||
| 217 | + | _ = conn.Close() | |||
| 218 | + | return nil, err | |||
| 219 | + | } | |||
| 220 | + | return x, nil | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func (x *XWM) boot() error { | |||
| 224 | + | conn := x.conn | |||
| 225 | + | ext := conn.Ext() | |||
| 226 | + | if !ext.Info(xturbo.ExtComposite).Present { | |||
| 227 | + | return ErrNoComposite | |||
| 228 | + | } | |||
| 229 | + | var visual, depth uint32 | |||
| 230 | + | if it := xproto.NewSetupIter(conn.SetupBytes(), 1); it.Next() { | |||
| 231 | + | if roots := it.Value().Roots(); roots.Next() { | |||
| 232 | + | visual, depth = uint32(roots.Value().RootVisual()), uint32(roots.Value().RootDepth()) | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | x.log.Info().Uint32("root", x.root).Uint32("visual", visual).Uint32("depth", depth). | |||
| 236 | + | Bool("xfixes", ext.Info(xturbo.ExtXFixes).Present). | |||
| 237 | + | Bool("res", ext.Info(xturbo.ExtRes).Present).Msg("X server") | |||
| 238 | + | ||||
| 239 | + | if err := conn.InternAtoms(atomNames[:], false, x.atoms[:]); err != nil { | |||
| 240 | + | return fmt.Errorf("xwm: intern atoms: %w", err) | |||
| 241 | + | } | |||
| 242 | + | xid, err := conn.NewXID() | |||
| 243 | + | if err != nil { | |||
| 244 | + | return fmt.Errorf("xwm: allocate window id: %w", err) | |||
| 245 | + | } | |||
| 246 | + | x.wmWin = xid | |||
| 247 | + | if ext.Info(xturbo.ExtXFixes).Present { | |||
| 248 | + | if x.selWin, err = conn.NewXID(); err != nil { | |||
| 249 | + | return fmt.Errorf("xwm: allocate window id: %w", err) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | b := conn.Buf() | |||
| 254 | + | x.expect(composite.QueryVersion(b, 0, 4), kindVersion, versionComposite, 0) | |||
| 255 | + | if ext.Info(xturbo.ExtXFixes).Present { | |||
| 256 | + | x.expect(xfixes.QueryVersion(b, 6, 0), kindVersion, versionXFixes, 0) | |||
| 257 | + | } | |||
| 258 | + | if ext.Info(xturbo.ExtRes).Present { | |||
| 259 | + | x.expect(res.QueryVersion(b, 1, 2), kindVersion, versionRes, 0) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | root := xproto.Window(x.root) | |||
| 263 | + | var rootMask xproto.ChangeWindowAttributesValues | |||
| 264 | + | rootMask.SetEventMask(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange) | |||
| 265 | + | x.expect(xproto.ChangeWindowAttributes(b, root, &rootMask), kindRootMask, 0, 0) | |||
| 266 | + | // Only one client may select SubstructureRedirect on the root: wait for | |||
| 267 | + | // the answer before touching anything another window manager owns. | |||
| 268 | + | if err := b.Err(); err != nil { | |||
| 269 | + | return fmt.Errorf("xwm: encode root mask: %w", err) | |||
| 270 | + | } | |||
| 271 | + | if err := conn.Sync(&x.h); err != nil { | |||
| 272 | + | return fmt.Errorf("xwm: root mask: %w", err) | |||
| 273 | + | } | |||
| 274 | + | if x.bootErr != nil { | |||
| 275 | + | return x.bootErr | |||
| 276 | + | } | |||
| 277 | + | composite.RedirectSubwindows(b, root, composite.RedirectManual) | |||
| 278 | + | ||||
| 279 | + | var wv xproto.CreateWindowValues | |||
| 280 | + | wv.SetEventMask(0) | |||
| 281 | + | xproto.CreateWindow(b, 0, xproto.Window(x.wmWin), root, -1, -1, 1, 1, 0, xproto.WindowClassInputOutput, 0, &wv) | |||
| 282 | + | name := []byte("neferwl") | |||
| 283 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMName]), | |||
| 284 | + | xproto.Atom(x.atoms[atomUTF8String]), 8, uint32(len(name)), name) | |||
| 285 | + | x.setProp32(x.root, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin}) | |||
| 286 | + | x.setProp32(x.wmWin, atomNetSupportingWMCheck, xproto.AtomWindow, []uint32{x.wmWin}) | |||
| 287 | + | // CurrentTime and no MANAGER announcement: this Xwayland is private to | |||
| 288 | + | // neferwl and nobody waits on these selections. | |||
| 289 | + | xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomWMS0]), 0) | |||
| 290 | + | xproto.SetSelectionOwner(b, xproto.Window(x.wmWin), xproto.Atom(x.atoms[atomNetWMCMS0]), 0) | |||
| 291 | + | ||||
| 292 | + | x.bootSelection(root) | |||
| 293 | + | ||||
| 294 | + | supported := [...]int{ | |||
| 295 | + | atomNetWMState, atomNetActiveWindow, atomNetWMStateFocused, | |||
| 296 | + | atomNetWMStateModal, atomNetWMStateFullscreen, atomNetClientList, | |||
| 297 | + | atomNetClientListStacking, atomNetSupportingWMCheck, atomNetWMName, atomNetWMPID, | |||
| 298 | + | atomNetWMWindowType, | |||
| 299 | + | } | |||
| 300 | + | var list [len(supported)]uint32 | |||
| 301 | + | for i, a := range supported { | |||
| 302 | + | list[i] = x.atoms[a] | |||
| 303 | + | } | |||
| 304 | + | x.setProp32(x.root, atomNetSupported, xproto.AtomAtom, list[:]) | |||
| 305 | + | x.setProp32(x.root, atomNetActiveWindow, xproto.AtomWindow, []uint32{0}) | |||
| 306 | + | x.setProp32(x.root, atomNetClientList, xproto.AtomWindow, nil) | |||
| 307 | + | x.setProp32(x.root, atomNetClientListStacking, xproto.AtomWindow, nil) | |||
| 308 | + | xproto.SetInputFocus(b, xproto.InputFocusNone, xproto.WindowNone, 0) | |||
| 309 | + | if err := b.Err(); err != nil { | |||
| 310 | + | return fmt.Errorf("xwm: encode setup requests: %w", err) | |||
| 311 | + | } | |||
| 312 | + | if err := conn.Sync(&x.h); err != nil { | |||
| 313 | + | return fmt.Errorf("xwm: root setup: %w", err) | |||
| 314 | + | } | |||
| 315 | + | if x.bootErr != nil { | |||
| 316 | + | return x.bootErr | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | if err := x.bootSettings(); err != nil { | |||
| 320 | + | return err | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // Windows that exist already (Xwayland restarted under a live client set). | |||
| 324 | + | x.expect(xproto.QueryTree(b, root), kindTree, 0, 0) | |||
| 325 | + | if err := conn.Sync(&x.h); err != nil { | |||
| 326 | + | return fmt.Errorf("xwm: query tree: %w", err) | |||
| 327 | + | } | |||
| 328 | + | return x.bootErr | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // Attach registers the X socket with the display and hands the windows found | |||
| 332 | + | // at boot to h. Display goroutine only. | |||
| 333 | + | func (x *XWM) Attach(d *server.Display, h host) error { | |||
| 334 | + | src, err := d.AddFDRW(x.conn.Fd(), x.onReadable, x.onWritable) | |||
| 335 | + | if err != nil { | |||
| 336 | + | return fmt.Errorf("xwm: watch X socket: %w", err) | |||
| 337 | + | } | |||
| 338 | + | x.attach(src, h, d.Post) | |||
| 339 | + | x.lp.(*displayLoop).d = d | |||
| 340 | + | return nil | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // loop is what the XWM needs from the display beyond the X socket source: | |||
| 344 | + | // fd sources for transfer pipes and one-shot timers. | |||
| 345 | + | type loop interface { | |||
| 346 | + | AddFD(fd int, onReadable, onWritable func()) (fdSource, error) | |||
| 347 | + | After(d time.Duration, fn func()) (cancel func()) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // displayLoop is the production loop: the display's fd sources, the host's timers. | |||
| 351 | + | type displayLoop struct { | |||
| 352 | + | d *server.Display | |||
| 353 | + | h host | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (l *displayLoop) AddFD(fd int, r, w func()) (fdSource, error) { | |||
| 357 | + | if l.d == nil { | |||
| 358 | + | return nil, errors.New("xwm: no display") | |||
| 359 | + | } | |||
| 360 | + | s, err := l.d.AddFDRW(fd, r, w) | |||
| 361 | + | if err != nil { | |||
| 362 | + | return nil, err | |||
| 363 | + | } | |||
| 364 | + | return s, nil | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func (l *displayLoop) After(d time.Duration, fn func()) func() { return l.h.After(d, fn) } | |||
| 368 | + | ||||
| 369 | + | // attach is Attach over its seams: the event source and the display's Post. | |||
| 370 | + | func (x *XWM) attach(src fdSource, h host, post func(func())) { | |||
| 371 | + | x.src, x.host, x.post = src, h, post | |||
| 372 | + | x.lp = &displayLoop{h: h} | |||
| 373 | + | x.flushFn = func() { | |||
| 374 | + | x.posted = false | |||
| 375 | + | x.flush() | |||
| 376 | + | } | |||
| 377 | + | for i := range x.tab.recs { | |||
| 378 | + | r := &x.tab.recs[i] | |||
| 379 | + | if r.kind == kindUnmanaged && r.mapped && !r.ready { // mapped before Attach | |||
| 380 | + | x.requestOverrideType(Slot(i + 1)) | |||
| 381 | + | } | |||
| 382 | + | if r.kind != kindManaged || r.id != 0 { | |||
| 383 | + | continue | |||
| 384 | + | } | |||
| 385 | + | s := Slot(i + 1) | |||
| 386 | + | x.adopt(s) | |||
| 387 | + | if r.mapping { // MapRequest or viewable before Attach | |||
| 388 | + | r.mapping = false | |||
| 389 | + | x.beginMap(s) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | x.flush() | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | // Close detaches from the display, drops every window from the host and closes | |||
| 396 | + | // the X connection. Idempotent. | |||
| 397 | + | func (x *XWM) Close() { | |||
| 398 | + | if x.closed { | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | x.closed = true | |||
| 402 | + | x.selClose() | |||
| 403 | + | s := x.stats | |||
| 404 | + | x.log.Info().Uint64("events", s.Events).Uint64("replies", s.Replies).Uint64("errors", s.Errors). | |||
| 405 | + | Uint64("unattributed", s.Unattributed).Uint64("stale", s.Stale).Uint64("ring_full", s.RingFull). | |||
| 406 | + | Uint64("legacy_id", s.LegacyID).Uint64("unmanaged_seen", s.UnmanagedSeen).Msg("X11 window management closed") | |||
| 407 | + | if x.src != nil { | |||
| 408 | + | x.src.Remove() | |||
| 409 | + | x.src = nil | |||
| 410 | + | } | |||
| 411 | + | if x.host != nil { | |||
| 412 | + | for i := range x.tab.recs { | |||
| 413 | + | r := &x.tab.recs[i] | |||
| 414 | + | switch { | |||
| 415 | + | case r.kind == kindManaged && r.id != 0: | |||
| 416 | + | r.id = 0 | |||
| 417 | + | x.host.Dissociate(Slot(i+1), true) | |||
| 418 | + | case r.kind == kindUnmanaged && r.ovPaired: | |||
| 419 | + | r.ovPaired = false | |||
| 420 | + | x.host.OverrideGone(Slot(i + 1)) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | x.unpairedN = 0 | |||
| 425 | + | _ = x.conn.Close() | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // CloseConn closes the connection of an XWM that was never attached. | |||
| 429 | + | func (x *XWM) CloseConn() { | |||
| 430 | + | if !x.closed { | |||
| 431 | + | x.selClose() | |||
| 432 | + | x.closed = true | |||
| 433 | + | _ = x.conn.Close() | |||
| 434 | + | } | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | // fail ends window management after an unrecoverable connection error. Xwayland | |||
| 438 | + | // keeps running; its windows no longer map. | |||
| 439 | + | func (x *XWM) fail(err error) { | |||
| 440 | + | if x.closed { | |||
| 441 | + | return | |||
| 442 | + | } | |||
| 443 | + | if errors.Is(err, io.EOF) || errors.Is(err, xturbo.ErrClosed) { | |||
| 444 | + | x.log.Info().Err(err).Msg("X11 window management stopped: Xwayland closed the connection") | |||
| 445 | + | } else { | |||
| 446 | + | x.log.Warn().Err(err).Msg("X11 window management stopped") | |||
| 447 | + | } | |||
| 448 | + | x.Close() | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | // Stats returns the counters; Close logs them. | |||
| 452 | + | func (x *XWM) Stats() Stats { return x.stats } | |||
| 453 | + | ||||
| 454 | + | func (x *XWM) onReadable() { | |||
| 455 | + | if x.closed { | |||
| 456 | + | return | |||
| 457 | + | } | |||
| 458 | + | if err := x.conn.Dispatch(&x.h); err != nil { | |||
| 459 | + | x.fail(err) | |||
| 460 | + | return | |||
| 461 | + | } | |||
| 462 | + | x.flush() | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func (x *XWM) onWritable() { x.flush() } | |||
| 466 | + | ||||
| 467 | + | // wake schedules one flush at the end of the current display iteration. | |||
| 468 | + | func (x *XWM) wake() { | |||
| 469 | + | if x.posted || x.closed || x.post == nil { | |||
| 470 | + | return | |||
| 471 | + | } | |||
| 472 | + | x.posted = true | |||
| 473 | + | x.post(x.flushFn) | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // flush sends what the handlers queued: pending configures, an XID prefetch, | |||
| 477 | + | // then the socket, arming POLLOUT only while it is full. | |||
| 478 | + | func (x *XWM) flush() { | |||
| 479 | + | if x.closed { | |||
| 480 | + | return | |||
| 481 | + | } | |||
| 482 | + | x.flushConfigures() | |||
| 483 | + | if x.conn.XIDsLow() { | |||
| 484 | + | _ = x.conn.PrefetchXIDs() | |||
| 485 | + | } | |||
| 486 | + | err := x.conn.Flush() | |||
| 487 | + | switch { | |||
| 488 | + | case err == nil: | |||
| 489 | + | if x.writable { | |||
| 490 | + | x.setWritable(false) | |||
| 491 | + | } | |||
| 492 | + | case errors.Is(err, xturbo.ErrWouldBlock): | |||
| 493 | + | if !x.writable { | |||
| 494 | + | x.setWritable(true) | |||
| 495 | + | } | |||
| 496 | + | default: | |||
| 497 | + | x.fail(err) | |||
| 498 | + | } | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func (x *XWM) setWritable(on bool) { | |||
| 502 | + | x.writable = on | |||
| 503 | + | if x.src == nil { | |||
| 504 | + | return | |||
| 505 | + | } | |||
| 506 | + | if err := x.src.SetWritable(on); err != nil { | |||
| 507 | + | x.fail(err) | |||
| 508 | + | } | |||
| 509 | + | } | |||
A internal/adapters/wayland/xwm/xwm_test.go +387 -0
| @@ -0,0 +1,387 @@ | |||||
| 1 | + | package xwm | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "bnema.dev/xturbo/xproto" | |||
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | "github.com/rs/zerolog" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func testLog() zerowrap.Logger { return zerowrap.Logger{Logger: zerolog.Nop()} } | |||
| 20 | + | ||||
| 21 | + | func bootPeer(t *testing.T, mutate func(p *xpeer)) (*XWM, *xpeer) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | fd, p := startXPeer(t, fullPeerExts()) | |||
| 24 | + | if mutate != nil { | |||
| 25 | + | p.set(mutate) | |||
| 26 | + | } | |||
| 27 | + | x, err := Boot(context.Background(), fd, testLog()) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | t.Cleanup(x.CloseConn) | |||
| 30 | + | return x, p | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func atomsOf(p xpeerProp) []uint32 { | |||
| 34 | + | out := make([]uint32, len(p.data)/4) | |||
| 35 | + | for i := range out { | |||
| 36 | + | out[i] = binary.LittleEndian.Uint32(p.data[4*i:]) | |||
| 37 | + | } | |||
| 38 | + | return out | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestBootRequests(t *testing.T) { | |||
| 42 | + | x, p := bootPeer(t, nil) | |||
| 43 | + | reqs := p.requests() | |||
| 44 | + | ||||
| 45 | + | first := -1 | |||
| 46 | + | for i, r := range reqs { | |||
| 47 | + | if r.major == opInternAtom { | |||
| 48 | + | first = i | |||
| 49 | + | break | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | require.NotEqual(t, -1, first) | |||
| 53 | + | for i := 0; i < atomCount; i++ { | |||
| 54 | + | r := reqs[first+i] | |||
| 55 | + | require.EqualValues(t, opInternAtom, r.major, i) | |||
| 56 | + | n := int(binary.LittleEndian.Uint16(r.raw[4:])) | |||
| 57 | + | require.Equal(t, atomNames[i], string(r.raw[8:8+n]), i) | |||
| 58 | + | require.Equal(t, p.atom(atomNames[i]), x.atoms[i]) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Root mask, then composite redirect, then the WM window and ownership. | |||
| 62 | + | var rootMask, redirect, create, s0, cm, focus int | |||
| 63 | + | rootMask, redirect, create, s0, cm, focus = -1, -1, -1, -1, -1, -1 | |||
| 64 | + | wm := x.wmWin | |||
| 65 | + | for i := first + atomCount; i < len(reqs); i++ { | |||
| 66 | + | r := reqs[i] | |||
| 67 | + | switch { | |||
| 68 | + | case r.major == opChangeWindowAttributes && r.u32(4) == peerRoot: | |||
| 69 | + | rootMask = i | |||
| 70 | + | require.EqualValues(t, 1<<11, r.u32(8)) | |||
| 71 | + | want := uint32(xproto.EventMaskSubstructureRedirect | xproto.EventMaskSubstructureNotify | xproto.EventMaskPropertyChange) | |||
| 72 | + | require.Equal(t, want, r.u32(12)) | |||
| 73 | + | case r.major == 142 && r.minor == 2: | |||
| 74 | + | redirect = i | |||
| 75 | + | require.EqualValues(t, peerRoot, r.u32(4)) | |||
| 76 | + | require.EqualValues(t, 1, r.raw[8], "manual") | |||
| 77 | + | case r.major == opCreateWindow && r.u32(4) == wm: | |||
| 78 | + | create = i | |||
| 79 | + | require.Equal(t, wm, r.u32(4)) | |||
| 80 | + | case r.major == opSetSelectionOwner && r.u32(8) == p.atom("WM_S0"): | |||
| 81 | + | s0 = i | |||
| 82 | + | require.Equal(t, wm, r.u32(4)) | |||
| 83 | + | case r.major == opSetSelectionOwner && r.u32(8) == p.atom("_NET_WM_CM_S0"): | |||
| 84 | + | cm = i | |||
| 85 | + | case r.major == opSetInputFocus: | |||
| 86 | + | focus = i | |||
| 87 | + | require.EqualValues(t, 0, r.u32(4), "focus None") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | for name, i := range map[string]int{"rootMask": rootMask, "redirect": redirect, "create": create, "WM_S0": s0, "CM_S0": cm, "focus": focus} { | |||
| 91 | + | require.NotEqual(t, -1, i, name) | |||
| 92 | + | } | |||
| 93 | + | require.Less(t, rootMask, redirect) | |||
| 94 | + | require.Less(t, redirect, create) | |||
| 95 | + | require.Less(t, create, s0) | |||
| 96 | + | ||||
| 97 | + | sup, ok := p.prop(peerRoot, "_NET_SUPPORTED") | |||
| 98 | + | require.True(t, ok) | |||
| 99 | + | var names []string | |||
| 100 | + | byAtom := map[uint32]string{} | |||
| 101 | + | for n, a := range p.atoms { | |||
| 102 | + | byAtom[a] = n | |||
| 103 | + | } | |||
| 104 | + | for _, a := range atomsOf(sup) { | |||
| 105 | + | names = append(names, byAtom[a]) | |||
| 106 | + | } | |||
| 107 | + | require.Equal(t, []string{ | |||
| 108 | + | "_NET_WM_STATE", "_NET_ACTIVE_WINDOW", "_NET_WM_STATE_FOCUSED", | |||
| 109 | + | "_NET_WM_STATE_MODAL", "_NET_WM_STATE_FULLSCREEN", "_NET_CLIENT_LIST", | |||
| 110 | + | "_NET_CLIENT_LIST_STACKING", "_NET_SUPPORTING_WM_CHECK", "_NET_WM_NAME", "_NET_WM_PID", | |||
| 111 | + | "_NET_WM_WINDOW_TYPE", | |||
| 112 | + | }, names) | |||
| 113 | + | require.NotContains(t, names, "_NET_WM_MOVERESIZE") | |||
| 114 | + | require.NotContains(t, names, "_NET_WM_STATE_MAXIMIZED_VERT") | |||
| 115 | + | ||||
| 116 | + | check, ok := p.prop(peerRoot, "_NET_SUPPORTING_WM_CHECK") | |||
| 117 | + | require.True(t, ok) | |||
| 118 | + | require.Equal(t, []uint32{wm}, atomsOf(check)) | |||
| 119 | + | check, ok = p.prop(wm, "_NET_SUPPORTING_WM_CHECK") | |||
| 120 | + | require.True(t, ok) | |||
| 121 | + | require.Equal(t, []uint32{wm}, atomsOf(check)) | |||
| 122 | + | name, ok := p.prop(wm, "_NET_WM_NAME") | |||
| 123 | + | require.True(t, ok) | |||
| 124 | + | require.Equal(t, "neferwl", string(name.data)) | |||
| 125 | + | active, ok := p.prop(peerRoot, "_NET_ACTIVE_WINDOW") | |||
| 126 | + | require.True(t, ok) | |||
| 127 | + | require.Equal(t, []uint32{0}, atomsOf(active)) | |||
| 128 | + | for _, l := range []string{"_NET_CLIENT_LIST", "_NET_CLIENT_LIST_STACKING"} { | |||
| 129 | + | v, ok := p.prop(peerRoot, l) | |||
| 130 | + | require.True(t, ok, l) | |||
| 131 | + | require.Empty(t, v.data, l) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestBootWMExists(t *testing.T) { | |||
| 136 | + | fd, p := startXPeer(t, fullPeerExts()) | |||
| 137 | + | p.set(func(p *xpeer) { p.denyRoot = true }) | |||
| 138 | + | x, err := Boot(context.Background(), fd, testLog()) | |||
| 139 | + | require.ErrorIs(t, err, ErrWMExists) | |||
| 140 | + | require.Nil(t, x) | |||
| 141 | + | // Nothing the running window manager owns was touched. | |||
| 142 | + | for _, r := range p.requests() { | |||
| 143 | + | require.NotEqual(t, uint8(opSetSelectionOwner), r.major) | |||
| 144 | + | if r.major == opChangeProperty { | |||
| 145 | + | require.NotEqual(t, uint32(peerRoot), r.u32(4)) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func TestBootNoComposite(t *testing.T) { | |||
| 151 | + | exts := fullPeerExts() | |||
| 152 | + | delete(exts, "Composite") | |||
| 153 | + | fd, _ := startXPeer(t, exts) | |||
| 154 | + | _, err := Boot(context.Background(), fd, testLog()) | |||
| 155 | + | require.ErrorIs(t, err, ErrNoComposite) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestBootOptionalExtensions(t *testing.T) { | |||
| 159 | + | exts := fullPeerExts() | |||
| 160 | + | delete(exts, "XFIXES") | |||
| 161 | + | delete(exts, "X-Resource") | |||
| 162 | + | fd, _ := startXPeer(t, exts) | |||
| 163 | + | x, err := Boot(context.Background(), fd, testLog()) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | x.CloseConn() | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestBootConnectFailure(t *testing.T) { | |||
| 169 | + | fd, p := startXPeer(t, fullPeerExts()) | |||
| 170 | + | p.hangup() | |||
| 171 | + | _, err := Boot(context.Background(), fd, testLog()) | |||
| 172 | + | require.Error(t, err) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestBootExistingWindows(t *testing.T) { | |||
| 176 | + | x, p := bootPeer(t, func(p *xpeer) { | |||
| 177 | + | p.children = []uint32{0x400001, 0x400002} | |||
| 178 | + | p.attrs[0x400002] = [2]byte{1, 2} // override-redirect, viewable | |||
| 179 | + | }) | |||
| 180 | + | _ = p | |||
| 181 | + | require.NotZero(t, x.tab.lookup(0x400001)) | |||
| 182 | + | require.NotZero(t, x.tab.lookup(0x400002)) | |||
| 183 | + | require.Equal(t, kindManaged, x.tab.rec(x.tab.lookup(0x400001)).kind) | |||
| 184 | + | require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x400002)).kind) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // readUntil dispatches the socket until cond holds. | |||
| 188 | + | func readUntil(t *testing.T, x *XWM, cond func() bool) { | |||
| 189 | + | t.Helper() | |||
| 190 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 191 | + | for !cond() { | |||
| 192 | + | if time.Now().After(deadline) { | |||
| 193 | + | t.Fatal("condition never held") | |||
| 194 | + | } | |||
| 195 | + | x.onReadable() | |||
| 196 | + | time.Sleep(time.Millisecond) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func newPost() (post func(func()), runs *[]func()) { | |||
| 201 | + | var q []func() | |||
| 202 | + | return func(f func()) { q = append(q, f) }, &q | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestAttachAnnouncesWindowsFoundAtBoot(t *testing.T) { | |||
| 206 | + | x, _ := bootPeer(t, func(p *xpeer) { | |||
| 207 | + | p.children = []uint32{0x400001, 0x400002} | |||
| 208 | + | p.attrs[0x400002] = [2]byte{1, 2} | |||
| 209 | + | }) | |||
| 210 | + | h := newMockhost(t) | |||
| 211 | + | src := newMockfdSource(t) | |||
| 212 | + | h.EXPECT().Created(x.tab.lookup(0x400001), uint32(0x400001)).Return(ports.WindowID(7)).Once() | |||
| 213 | + | post, _ := newPost() | |||
| 214 | + | x.attach(src, h, post) | |||
| 215 | + | require.Equal(t, ports.WindowID(7), x.tab.rec(x.tab.lookup(0x400001)).id) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func attached(t *testing.T) (*XWM, *xpeer, *mockhost, *mockfdSource, *[]func()) { | |||
| 219 | + | t.Helper() | |||
| 220 | + | x, p := bootPeer(t, nil) | |||
| 221 | + | h := newMockhost(t) | |||
| 222 | + | src := newMockfdSource(t) | |||
| 223 | + | post, runs := newPost() | |||
| 224 | + | x.attach(src, h, post) | |||
| 225 | + | return x, p, h, src, runs | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestCreateAndDestroyManaged(t *testing.T) { | |||
| 229 | + | x, p, h, src, _ := attached(t) | |||
| 230 | + | const win = 0x500001 | |||
| 231 | + | h.EXPECT().Created(mock.Anything, uint32(win)).Return(ports.WindowID(3)).Once() | |||
| 232 | + | p.createNotify(win, false) | |||
| 233 | + | readUntil(t, x, func() bool { return x.tab.lookup(win) != 0 }) | |||
| 234 | + | s := x.tab.lookup(win) | |||
| 235 | + | require.Equal(t, kindManaged, x.tab.rec(s).kind) | |||
| 236 | + | require.Equal(t, ports.WindowID(3), x.tab.rec(s).id) | |||
| 237 | + | ||||
| 238 | + | // The window is watched for property and focus changes. | |||
| 239 | + | req, _ := p.waitReq(0, func(r xpeerReq) bool { return r.major == opChangeWindowAttributes && r.u32(4) == win }) | |||
| 240 | + | require.EqualValues(t, xproto.EventMaskPropertyChange|xproto.EventMaskFocusChange, req.u32(12)) | |||
| 241 | + | ||||
| 242 | + | h.EXPECT().Dissociate(s, true).Once() | |||
| 243 | + | p.destroyNotify(win) | |||
| 244 | + | readUntil(t, x, func() bool { return x.tab.lookup(win) == 0 }) | |||
| 245 | + | require.Zero(t, x.tab.n) | |||
| 246 | + | _ = src | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // The X server reports the XWM's own check window to it like any other | |||
| 250 | + | // child of the root; it is never adopted. | |||
| 251 | + | func TestOwnWindowNeverAdopted(t *testing.T) { | |||
| 252 | + | x, p, _, _, _ := attached(t) // the mock fails the test on any host call | |||
| 253 | + | p.createNotify(x.wmWin, false) | |||
| 254 | + | const marker = 0x500003 | |||
| 255 | + | p.createNotify(marker, true) | |||
| 256 | + | readUntil(t, x, func() bool { return x.tab.lookup(marker) != 0 }) | |||
| 257 | + | require.Zero(t, x.tab.lookup(x.wmWin)) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | func TestOverrideRedirectNeverReachesHost(t *testing.T) { | |||
| 261 | + | x, p, _, _, _ := attached(t) // the mock fails the test on any host call | |||
| 262 | + | p.createNotify(0x500002, true) | |||
| 263 | + | readUntil(t, x, func() bool { return x.tab.lookup(0x500002) != 0 }) | |||
| 264 | + | require.Equal(t, kindUnmanaged, x.tab.rec(x.tab.lookup(0x500002)).kind) | |||
| 265 | + | require.EqualValues(t, 1, x.Stats().UnmanagedSeen) | |||
| 266 | + | p.destroyNotify(0x500002) | |||
| 267 | + | readUntil(t, x, func() bool { return x.tab.lookup(0x500002) == 0 }) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func TestCloseDissociatesEverything(t *testing.T) { | |||
| 271 | + | x, p, h, src, _ := attached(t) | |||
| 272 | + | for _, win := range []uint32{0x500001, 0x500002} { | |||
| 273 | + | h.EXPECT().Created(mock.Anything, win).Return(ports.WindowID(win)).Once() | |||
| 274 | + | p.createNotify(win, false) | |||
| 275 | + | } | |||
| 276 | + | p.createNotify(0x500003, true) | |||
| 277 | + | readUntil(t, x, func() bool { return x.tab.lookup(0x500003) != 0 }) | |||
| 278 | + | h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once() | |||
| 279 | + | h.EXPECT().Dissociate(x.tab.lookup(0x500002), true).Once() | |||
| 280 | + | src.EXPECT().Remove().Once() | |||
| 281 | + | x.Close() | |||
| 282 | + | x.Close() // idempotent | |||
| 283 | + | require.True(t, x.closed) | |||
| 284 | + | require.Equal(t, -1, x.conn.Fd()) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestEOFClosesXWM(t *testing.T) { | |||
| 288 | + | x, p, h, src, _ := attached(t) | |||
| 289 | + | h.EXPECT().Created(mock.Anything, uint32(0x500001)).Return(ports.WindowID(1)).Once() | |||
| 290 | + | p.createNotify(0x500001, false) | |||
| 291 | + | readUntil(t, x, func() bool { return x.tab.lookup(0x500001) != 0 }) | |||
| 292 | + | h.EXPECT().Dissociate(x.tab.lookup(0x500001), true).Once() | |||
| 293 | + | src.EXPECT().Remove().Once() | |||
| 294 | + | p.hangup() | |||
| 295 | + | readUntil(t, x, func() bool { return x.closed }) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestWakeCoalescesFlush(t *testing.T) { | |||
| 299 | + | x, _, _, _, runs := attached(t) | |||
| 300 | + | x.wake() | |||
| 301 | + | x.wake() | |||
| 302 | + | x.wake() | |||
| 303 | + | require.Len(t, *runs, 1) | |||
| 304 | + | (*runs)[0]() | |||
| 305 | + | x.wake() | |||
| 306 | + | require.Len(t, *runs, 2) | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func TestWriteBackpressure(t *testing.T) { | |||
| 310 | + | x, p, _, src, _ := attached(t) | |||
| 311 | + | p.pause() | |||
| 312 | + | b := x.conn.Buf() | |||
| 313 | + | big := make([]byte, 64<<10) | |||
| 314 | + | for i := 0; i < 64; i++ { // 4 MiB: more than any socket buffer | |||
| 315 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, xproto.Window(x.root), xproto.AtomWmName, xproto.AtomString, 8, uint32(len(big)), big) | |||
| 316 | + | } | |||
| 317 | + | src.EXPECT().SetWritable(true).Return(nil).Once() | |||
| 318 | + | x.flush() | |||
| 319 | + | require.True(t, x.writable) | |||
| 320 | + | x.flush() // still blocked: SetWritable is not called again | |||
| 321 | + | x.onWritable() | |||
| 322 | + | require.True(t, x.writable) | |||
| 323 | + | ||||
| 324 | + | drained := false | |||
| 325 | + | src.EXPECT().SetWritable(false).RunAndReturn(func(bool) error { drained = true; return nil }).Once() | |||
| 326 | + | p.resume() | |||
| 327 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 328 | + | for !drained { | |||
| 329 | + | require.True(t, time.Now().Before(deadline), "never drained") | |||
| 330 | + | x.onWritable() | |||
| 331 | + | time.Sleep(time.Millisecond) | |||
| 332 | + | } | |||
| 333 | + | require.False(t, x.writable) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | func TestFlushWithNothingQueuedSendsNothing(t *testing.T) { | |||
| 337 | + | x, p, _, _, _ := attached(t) | |||
| 338 | + | n := len(p.requests()) | |||
| 339 | + | x.flush() | |||
| 340 | + | time.Sleep(20 * time.Millisecond) | |||
| 341 | + | require.Len(t, p.requests(), n, "nothing queued, nothing sent") | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // bootOnlyFiles run only from boot() (XSETTINGS manager, root cursor). | |||
| 345 | + | var bootOnlyFiles = map[string]bool{"selection_xsettings.go": true, "cursor.go": true} | |||
| 346 | + | ||||
| 347 | + | // The XWM never blocks after Boot: NewXID, Sync and InternAtoms are startup | |||
| 348 | + | // only, so they may appear in production code only inside boot(). | |||
| 349 | + | func TestNoBlockingCallsOutsideBoot(t *testing.T) { | |||
| 350 | + | files, err := os.ReadDir(".") | |||
| 351 | + | require.NoError(t, err) | |||
| 352 | + | for _, f := range files { | |||
| 353 | + | name := f.Name() | |||
| 354 | + | if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { | |||
| 355 | + | continue | |||
| 356 | + | } | |||
| 357 | + | src, err := os.ReadFile(name) | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | text := string(src) | |||
| 360 | + | if bootOnlyFiles[name] { | |||
| 361 | + | continue | |||
| 362 | + | } | |||
| 363 | + | lo, hi := 0, 0 | |||
| 364 | + | if name == "xwm.go" { | |||
| 365 | + | lo = strings.Index(text, "func (x *XWM) boot() error") | |||
| 366 | + | hi = strings.Index(text, "// Attach registers") | |||
| 367 | + | require.Greater(t, hi, lo) | |||
| 368 | + | } | |||
| 369 | + | for _, call := range []string{".NewXID(", ".Sync(", ".InternAtoms("} { | |||
| 370 | + | for _, at := range allIndex(text, call) { | |||
| 371 | + | require.True(t, name == "xwm.go" && at > lo && at < hi, "%s: %s outside boot", name, call) | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func allIndex(s, sub string) []int { | |||
| 378 | + | var out []int | |||
| 379 | + | for off := 0; ; { | |||
| 380 | + | i := strings.Index(s[off:], sub) | |||
| 381 | + | if i < 0 { | |||
| 382 | + | return out | |||
| 383 | + | } | |||
| 384 | + | out = append(out, off+i) | |||
| 385 | + | off += i + len(sub) | |||
| 386 | + | } | |||
| 387 | + | } | |||
A internal/adapters/wayland/xwm_selection_mock_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package wayland | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newMockxwmSelection creates a new instance of mockxwmSelection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func newMockxwmSelection(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *mockxwmSelection { | |||
| 17 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 18 | + | helper.Helper() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | mock := &mockxwmSelection{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { | |||
| 25 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 26 | + | helper.Helper() | |||
| 27 | + | } | |||
| 28 | + | mock.AssertExpectations(t) | |||
| 29 | + | }) | |||
| 30 | + | ||||
| 31 | + | return mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // mockxwmSelection is an autogenerated mock type for the xwmSelection type | |||
| 35 | + | type mockxwmSelection struct { | |||
| 36 | + | mock.Mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | type mockxwmSelection_Expecter struct { | |||
| 40 | + | mock *mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (_m *mockxwmSelection) EXPECT() *mockxwmSelection_Expecter { | |||
| 44 | + | return &mockxwmSelection_Expecter{mock: &_m.Mock} | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // SelectionReceive provides a mock function for the type mockxwmSelection | |||
| 48 | + | func (_mock *mockxwmSelection) SelectionReceive(kind int, mime string, fd int) { | |||
| 49 | + | _mock.Called(kind, mime, fd) | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // mockxwmSelection_SelectionReceive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectionReceive' | |||
| 54 | + | type mockxwmSelection_SelectionReceive_Call struct { | |||
| 55 | + | *mock.Call | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // SelectionReceive is a helper method to define mock.On call | |||
| 59 | + | // - kind int | |||
| 60 | + | // - mime string | |||
| 61 | + | // - fd int | |||
| 62 | + | func (_e *mockxwmSelection_Expecter) SelectionReceive(kind any, mime any, fd any) *mockxwmSelection_SelectionReceive_Call { | |||
| 63 | + | return &mockxwmSelection_SelectionReceive_Call{Call: _e.mock.On("SelectionReceive", kind, mime, fd)} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (_c *mockxwmSelection_SelectionReceive_Call) Run(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call { | |||
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 68 | + | var arg0 int | |||
| 69 | + | if args[0] != nil { | |||
| 70 | + | arg0 = args[0].(int) | |||
| 71 | + | } | |||
| 72 | + | var arg1 string | |||
| 73 | + | if args[1] != nil { | |||
| 74 | + | arg1 = args[1].(string) | |||
| 75 | + | } | |||
| 76 | + | var arg2 int | |||
| 77 | + | if args[2] != nil { | |||
| 78 | + | arg2 = args[2].(int) | |||
| 79 | + | } | |||
| 80 | + | run( | |||
| 81 | + | arg0, | |||
| 82 | + | arg1, | |||
| 83 | + | arg2, | |||
| 84 | + | ) | |||
| 85 | + | }) | |||
| 86 | + | return _c | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (_c *mockxwmSelection_SelectionReceive_Call) Return() *mockxwmSelection_SelectionReceive_Call { | |||
| 90 | + | _c.Call.Return() | |||
| 91 | + | return _c | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (_c *mockxwmSelection_SelectionReceive_Call) RunAndReturn(run func(kind int, mime string, fd int)) *mockxwmSelection_SelectionReceive_Call { | |||
| 95 | + | _c.Run(run) | |||
| 96 | + | return _c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // WaylandSelection provides a mock function for the type mockxwmSelection | |||
| 100 | + | func (_mock *mockxwmSelection) WaylandSelection(kind int, mimes []string) { | |||
| 101 | + | _mock.Called(kind, mimes) | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // mockxwmSelection_WaylandSelection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaylandSelection' | |||
| 106 | + | type mockxwmSelection_WaylandSelection_Call struct { | |||
| 107 | + | *mock.Call | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // WaylandSelection is a helper method to define mock.On call | |||
| 111 | + | // - kind int | |||
| 112 | + | // - mimes []string | |||
| 113 | + | func (_e *mockxwmSelection_Expecter) WaylandSelection(kind any, mimes any) *mockxwmSelection_WaylandSelection_Call { | |||
| 114 | + | return &mockxwmSelection_WaylandSelection_Call{Call: _e.mock.On("WaylandSelection", kind, mimes)} | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (_c *mockxwmSelection_WaylandSelection_Call) Run(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call { | |||
| 118 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 119 | + | var arg0 int | |||
| 120 | + | if args[0] != nil { | |||
| 121 | + | arg0 = args[0].(int) | |||
| 122 | + | } | |||
| 123 | + | var arg1 []string | |||
| 124 | + | if args[1] != nil { | |||
| 125 | + | arg1 = args[1].([]string) | |||
| 126 | + | } | |||
| 127 | + | run( | |||
| 128 | + | arg0, | |||
| 129 | + | arg1, | |||
| 130 | + | ) | |||
| 131 | + | }) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (_c *mockxwmSelection_WaylandSelection_Call) Return() *mockxwmSelection_WaylandSelection_Call { | |||
| 136 | + | _c.Call.Return() | |||
| 137 | + | return _c | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (_c *mockxwmSelection_WaylandSelection_Call) RunAndReturn(run func(kind int, mimes []string)) *mockxwmSelection_WaylandSelection_Call { | |||
| 141 | + | _c.Run(run) | |||
| 142 | + | return _c | |||
| 143 | + | } | |||
A internal/adapters/xwayland/builtin.go +216 -0
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | package xwayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "syscall" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "golang.org/x/sys/unix" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type builtinStarter struct{} | |||
| 18 | + | ||||
| 19 | + | func (builtinStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | |||
| 20 | + | // Files own all setup descriptors until Start succeeds and Attach takes | |||
| 21 | + | // the Wayland socket. This also unwinds partial setup failures. | |||
| 22 | + | var files []*os.File | |||
| 23 | + | defer func() { | |||
| 24 | + | for _, f := range files { | |||
| 25 | + | if f != nil { | |||
| 26 | + | f.Close() | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | }() | |||
| 30 | + | pair := func(name string) (int, error) { | |||
| 31 | + | fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return 0, err | |||
| 34 | + | } | |||
| 35 | + | i := len(files) | |||
| 36 | + | files = append(files, os.NewFile(uintptr(fds[0]), name+" parent"), os.NewFile(uintptr(fds[1]), name+" child")) | |||
| 37 | + | return i, nil | |||
| 38 | + | } | |||
| 39 | + | wl, err := pair("wayland") | |||
| 40 | + | if err != nil { | |||
| 41 | + | return err | |||
| 42 | + | } | |||
| 43 | + | wm, err := pair("wm") | |||
| 44 | + | if err != nil { | |||
| 45 | + | return err | |||
| 46 | + | } | |||
| 47 | + | var pipe [2]int | |||
| 48 | + | if err := unix.Pipe2(pipe[:], unix.O_CLOEXEC); err != nil { | |||
| 49 | + | return err | |||
| 50 | + | } | |||
| 51 | + | rd := os.NewFile(uintptr(pipe[0]), "displayfd read") | |||
| 52 | + | wr := os.NewFile(uintptr(pipe[1]), "displayfd write") | |||
| 53 | + | files = append(files, rd, wr) | |||
| 54 | + | args := []string{d.Name(), "-rootless"} | |||
| 55 | + | for i := range d.sockets { | |||
| 56 | + | args = append(args, "-listenfd", strconv.Itoa(3+i)) | |||
| 57 | + | } | |||
| 58 | + | wlChild := 3 + len(d.sockets) | |||
| 59 | + | args = append(args, "-wm", strconv.Itoa(wlChild+1), "-displayfd", strconv.Itoa(wlChild+2)) | |||
| 60 | + | cmd := exec.Command(d.opts.Binary, args...) | |||
| 61 | + | for _, e := range d.opts.Env { | |||
| 62 | + | if !strings.HasPrefix(e, "WAYLAND_DISPLAY=") && !strings.HasPrefix(e, "WAYLAND_SOCKET=") { | |||
| 63 | + | cmd.Env = append(cmd.Env, e) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(wlChild)) | |||
| 67 | + | cmd.ExtraFiles = append(append([]*os.File(nil), d.sockets...), files[wl+1], files[wm+1], wr) | |||
| 68 | + | cmd.Stderr = stderr | |||
| 69 | + | cmd.WaitDelay = 2 * time.Second | |||
| 70 | + | cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} | |||
| 71 | + | if err := cmd.Start(); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | files[wl+1].Close() | |||
| 75 | + | files[wm+1].Close() | |||
| 76 | + | wr.Close() | |||
| 77 | + | d.opts.Log.Info().Str("display", d.Name()).Int("pid", cmd.Process.Pid).Msg("Xwayland started") | |||
| 78 | + | // Detach the Wayland descriptor from os.File ownership before handing it | |||
| 79 | + | // to the display (including its finalizers). | |||
| 80 | + | if d.opts.Attach != nil { | |||
| 81 | + | // Dup transfers an independent descriptor; closing the File wrapper below | |||
| 82 | + | // cannot race with the display's ownership of the transferred descriptor. | |||
| 83 | + | var wlFD int | |||
| 84 | + | wlFD, err = unix.FcntlInt(uintptr(files[wl].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |||
| 85 | + | if err == nil { | |||
| 86 | + | d.opts.Attach(wlFD) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | files[wl].Close() | |||
| 90 | + | done := make(chan error, 1) | |||
| 91 | + | procGone := make(chan struct{}) | |||
| 92 | + | go func() { done <- cmd.Wait(); close(procGone) }() | |||
| 93 | + | if err != nil { | |||
| 94 | + | stopGroup(cmd, done) | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | exited, readyErr := waitReady(ctx, pipe[0], done) | |||
| 98 | + | if readyErr != nil { | |||
| 99 | + | err := readyErr | |||
| 100 | + | if errors.Is(err, errReadinessTimeout) { | |||
| 101 | + | d.opts.Log.Warn().Str("display", d.Name()).Msg("Xwayland readiness timed out") | |||
| 102 | + | } | |||
| 103 | + | // Join Wait unless readiness already observed process exit. | |||
| 104 | + | if !exited { | |||
| 105 | + | stopGroup(cmd, done) | |||
| 106 | + | } | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | rd.Close() | |||
| 110 | + | d.opts.Log.Info().Str("display", d.Name()).Msg("Xwayland ready") | |||
| 111 | + | if d.opts.AttachWM != nil { | |||
| 112 | + | // The WM socket is bootstrapped only now: Xwayland serves it after its | |||
| 113 | + | // Wayland round trips, which the display goroutine must be free to answer. | |||
| 114 | + | wmFD, err := unix.FcntlInt(uintptr(files[wm].Fd()), unix.F_DUPFD_CLOEXEC, 0) | |||
| 115 | + | if err != nil { | |||
| 116 | + | stopGroup(cmd, done) | |||
| 117 | + | return err | |||
| 118 | + | } | |||
| 119 | + | // The context ends with the process or the caller's context, so a | |||
| 120 | + | // bootstrap against a dead or silent Xwayland aborts at once. | |||
| 121 | + | wmCtx, cancel := context.WithCancel(ctx) | |||
| 122 | + | watched := make(chan struct{}) | |||
| 123 | + | go func() { | |||
| 124 | + | defer close(watched) | |||
| 125 | + | select { | |||
| 126 | + | case <-procGone: | |||
| 127 | + | cancel() | |||
| 128 | + | case <-wmCtx.Done(): | |||
| 129 | + | } | |||
| 130 | + | }() | |||
| 131 | + | stop := d.opts.AttachWM(wmCtx, wmFD) | |||
| 132 | + | cancel() | |||
| 133 | + | <-watched | |||
| 134 | + | if stop != nil { | |||
| 135 | + | // Runs before start returns so the restart loop never overlaps two XWMs. | |||
| 136 | + | defer stop() | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | select { | |||
| 140 | + | case err := <-done: | |||
| 141 | + | return err | |||
| 142 | + | case <-ctx.Done(): | |||
| 143 | + | stopGroup(cmd, done) | |||
| 144 | + | return ctx.Err() | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | var errReadinessTimeout = errors.New("xwayland readiness timed out") | |||
| 149 | + | ||||
| 150 | + | // waitReady uses bounded poll waits rather than a pipe reader goroutine. | |||
| 151 | + | // The result reports whether cmd.Wait was already joined. | |||
| 152 | + | func waitReady(ctx context.Context, fd int, done <-chan error) (bool, error) { | |||
| 153 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 154 | + | var buf [32]byte | |||
| 155 | + | n := 0 | |||
| 156 | + | for { | |||
| 157 | + | select { | |||
| 158 | + | case err := <-done: | |||
| 159 | + | if err == nil { | |||
| 160 | + | err = errors.New("xwayland exited before readiness") | |||
| 161 | + | } | |||
| 162 | + | return true, err | |||
| 163 | + | default: | |||
| 164 | + | } | |||
| 165 | + | if ctx.Err() != nil { | |||
| 166 | + | return false, ctx.Err() | |||
| 167 | + | } | |||
| 168 | + | if time.Now().After(deadline) { | |||
| 169 | + | return false, errReadinessTimeout | |||
| 170 | + | } | |||
| 171 | + | // A closed pipe also covers process exit, including exec's stderr delay. | |||
| 172 | + | p := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |||
| 173 | + | _, err := unix.Poll(p, 50) | |||
| 174 | + | if errors.Is(err, unix.EINTR) { | |||
| 175 | + | continue | |||
| 176 | + | } | |||
| 177 | + | if err != nil { | |||
| 178 | + | return false, err | |||
| 179 | + | } | |||
| 180 | + | if p[0].Revents == 0 { | |||
| 181 | + | continue | |||
| 182 | + | } | |||
| 183 | + | if p[0].Revents&unix.POLLIN != 0 { | |||
| 184 | + | count, err := unix.Read(fd, buf[n:]) | |||
| 185 | + | if errors.Is(err, unix.EINTR) { | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | if err != nil { | |||
| 189 | + | return false, err | |||
| 190 | + | } | |||
| 191 | + | n += count | |||
| 192 | + | if n > 0 && buf[n-1] == '\n' { | |||
| 193 | + | if _, err := strconv.Atoi(strings.TrimSpace(string(buf[:n]))); err != nil { | |||
| 194 | + | return false, fmt.Errorf("xwayland displayfd: %w", err) | |||
| 195 | + | } | |||
| 196 | + | return false, nil | |||
| 197 | + | } | |||
| 198 | + | if n == len(buf) { | |||
| 199 | + | return false, errors.New("invalid Xwayland displayfd response") | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | if p[0].Revents&(unix.POLLHUP|unix.POLLERR|unix.POLLNVAL) != 0 { | |||
| 203 | + | return false, errors.New("xwayland exited before readiness") | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func stopGroup(cmd *exec.Cmd, done <-chan error) { | |||
| 209 | + | _ = unix.Kill(-cmd.Process.Pid, unix.SIGTERM) | |||
| 210 | + | select { | |||
| 211 | + | case <-done: | |||
| 212 | + | case <-time.After(2 * time.Second): | |||
| 213 | + | _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL) | |||
| 214 | + | <-done | |||
| 215 | + | } | |||
| 216 | + | } | |||
A internal/adapters/xwayland/starter_mock_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package xwayland | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // newMockstarter creates a new instance of mockstarter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func newMockstarter(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *mockstarter { | |||
| 19 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 20 | + | helper.Helper() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | mock := &mockstarter{} | |||
| 24 | + | mock.Mock.Test(t) | |||
| 25 | + | ||||
| 26 | + | t.Cleanup(func() { | |||
| 27 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 28 | + | helper.Helper() | |||
| 29 | + | } | |||
| 30 | + | mock.AssertExpectations(t) | |||
| 31 | + | }) | |||
| 32 | + | ||||
| 33 | + | return mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // mockstarter is an autogenerated mock type for the starter type | |||
| 37 | + | type mockstarter struct { | |||
| 38 | + | mock.Mock | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type mockstarter_Expecter struct { | |||
| 42 | + | mock *mock.Mock | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (_m *mockstarter) EXPECT() *mockstarter_Expecter { | |||
| 46 | + | return &mockstarter_Expecter{mock: &_m.Mock} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // start provides a mock function for the type mockstarter | |||
| 50 | + | func (_mock *mockstarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | |||
| 51 | + | ret := _mock.Called(ctx, d, stderr) | |||
| 52 | + | ||||
| 53 | + | if len(ret) == 0 { | |||
| 54 | + | panic("no return value specified for start") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var r0 error | |||
| 58 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *Display, *logWriter) error); ok { | |||
| 59 | + | r0 = returnFunc(ctx, d, stderr) | |||
| 60 | + | } else { | |||
| 61 | + | r0 = ret.Error(0) | |||
| 62 | + | } | |||
| 63 | + | return r0 | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // mockstarter_start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'start' | |||
| 67 | + | type mockstarter_start_Call struct { | |||
| 68 | + | *mock.Call | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // start is a helper method to define mock.On call | |||
| 72 | + | // - ctx context.Context | |||
| 73 | + | // - d *Display | |||
| 74 | + | // - stderr *logWriter | |||
| 75 | + | func (_e *mockstarter_Expecter) start(ctx any, d any, stderr any) *mockstarter_start_Call { | |||
| 76 | + | return &mockstarter_start_Call{Call: _e.mock.On("start", ctx, d, stderr)} | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (_c *mockstarter_start_Call) Run(run func(ctx context.Context, d *Display, stderr *logWriter)) *mockstarter_start_Call { | |||
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 81 | + | var arg0 context.Context | |||
| 82 | + | if args[0] != nil { | |||
| 83 | + | arg0 = args[0].(context.Context) | |||
| 84 | + | } | |||
| 85 | + | var arg1 *Display | |||
| 86 | + | if args[1] != nil { | |||
| 87 | + | arg1 = args[1].(*Display) | |||
| 88 | + | } | |||
| 89 | + | var arg2 *logWriter | |||
| 90 | + | if args[2] != nil { | |||
| 91 | + | arg2 = args[2].(*logWriter) | |||
| 92 | + | } | |||
| 93 | + | run( | |||
| 94 | + | arg0, | |||
| 95 | + | arg1, | |||
| 96 | + | arg2, | |||
| 97 | + | ) | |||
| 98 | + | }) | |||
| 99 | + | return _c | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (_c *mockstarter_start_Call) Return(err error) *mockstarter_start_Call { | |||
| 103 | + | _c.Call.Return(err) | |||
| 104 | + | return _c | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (_c *mockstarter_start_Call) RunAndReturn(run func(ctx context.Context, d *Display, stderr *logWriter) error) *mockstarter_start_Call { | |||
| 108 | + | _c.Call.Return(run) | |||
| 109 | + | return _c | |||
| 110 | + | } | |||
M internal/adapters/xwayland/xwayland.go +46 -18
| @@ -1,6 +1,5 @@ | |||||
| 1 | - | // Package xwayland runs X11 clients through xwayland-satellite, started on | 1 | + | // Package xwayland starts an X11 backend on demand. NeferWL owns the display |
| 2 | - | // demand: neferwl owns the X11 display sockets and starts the satellite | 2 | + | // sockets and starts the selected backend when the first client connects. |
| 3 | - | // when the first X11 client connects, and again after it exits. | |||
| 4 | package xwayland | 3 | package xwayland | ||
| 5 | 4 | ||||
| 6 | import ( | 5 | import ( | ||
| @@ -17,15 +16,26 @@ import ( | |||||
| 17 | "syscall" | 16 | "syscall" | ||
| 18 | "time" | 17 | "time" | ||
| 19 | 18 | ||||
| 19 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 20 | "github.com/bnema/zerowrap" | 20 | "github.com/bnema/zerowrap" | ||
| 21 | "golang.org/x/sys/unix" | 21 | "golang.org/x/sys/unix" | ||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | // Options configures the X11 display. | 24 | // Options configures the X11 display. | ||
| 25 | type Options struct { | 25 | type Options struct { | ||
| 26 | - | // Binary is the xwayland-satellite executable. | 26 | + | Backend ports.XwaylandBackend |
| 27 | + | // Attach takes ownership of the Wayland descriptor right after the backend | |||
| 28 | + | // starts. | |||
| 29 | + | Attach func(wlFD int) | |||
| 30 | + | // AttachWM takes ownership of the window manager descriptor once the | |||
| 31 | + | // backend is ready. It may block to bootstrap the X11 connection; ctx ends | |||
| 32 | + | // when the backend exits or the run is cancelled, and the bootstrap must | |||
| 33 | + | // then give up. The returned stop (nil for none) is called before the run | |||
| 34 | + | // ends, on backend exit or context cancellation. | |||
| 35 | + | AttachWM func(ctx context.Context, wmFD int) (stop func()) | |||
| 36 | + | // Binary is the selected backend executable. | |||
| 27 | Binary string | 37 | Binary string | ||
| 28 | - | // Env is the satellite's environment; it must hold WAYLAND_DISPLAY. | 38 | + | // Env is the backend environment; satellite requires WAYLAND_DISPLAY. |
| 29 | Env []string | 39 | Env []string | ||
| 30 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | 40 | // Dir holds the X11 sockets and TmpDir the lock files: /tmp/.X11-unix | ||
| 31 | // and /tmp outside tests. | 41 | // and /tmp outside tests. | ||
| @@ -35,8 +45,9 @@ type Options struct { | |||||
| 35 | Log zerowrap.Logger | 45 | Log zerowrap.Logger | ||
| 36 | } | 46 | } | ||
| 37 | 47 | ||||
| 38 | - | // Display is an X11 display served by xwayland-satellite. | 48 | + | // Display is an on-demand X11 display. |
| 39 | type Display struct { | 49 | type Display struct { | ||
| 50 | + | starter starter | |||
| 40 | opts Options | 51 | opts Options | ||
| 41 | n int | 52 | n int | ||
| 42 | sockets []*os.File // unix socket, then the abstract one | 53 | sockets []*os.File // unix socket, then the abstract one | ||
| @@ -81,7 +92,10 @@ func Open(opts Options) (*Display, error) { | |||||
| 81 | _ = os.Remove(lock) | 92 | _ = os.Remove(lock) | ||
| 82 | return nil, fmt.Errorf("write X11 lock: %w", err) | 93 | return nil, fmt.Errorf("write X11 lock: %w", err) | ||
| 83 | } | 94 | } | ||
| 84 | - | d := &Display{opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))} | 95 | + | d := &Display{starter: satelliteStarter{}, opts: opts, n: n, lock: lock, socket: filepath.Join(opts.Dir, fmt.Sprintf("X%d", n))} |
| 96 | + | if opts.Backend == ports.XwaylandBuiltin { | |||
| 97 | + | d.starter = builtinStarter{} | |||
| 98 | + | } | |||
| 85 | if err := d.listen(); err != nil { | 99 | if err := d.listen(); err != nil { | ||
| 86 | last = err | 100 | last = err | ||
| 87 | d.Close() | 101 | d.Close() | ||
| @@ -212,15 +226,15 @@ func (d *Display) Run(ctx context.Context) error { | |||||
| 212 | return err | 226 | return err | ||
| 213 | } | 227 | } | ||
| 214 | start := time.Now() | 228 | start := time.Now() | ||
| 215 | - | stderr := &logWriter{log: d.opts.Log} | 229 | + | stderr := &logWriter{log: d.opts.Log, backend: d.backendName()} |
| 216 | - | err := d.serve(ctx, stderr) | 230 | + | err := d.starter.start(ctx, d, stderr) |
| 217 | if ctx.Err() != nil { | 231 | if ctx.Err() != nil { | ||
| 218 | return nil | 232 | return nil | ||
| 219 | } | 233 | } | ||
| 220 | if err != nil { | 234 | if err != nil { | ||
| 221 | - | d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg("xwayland-satellite exited") | 235 | + | d.opts.Log.Warn().Err(err).Strs("stderr", stderr.tail()).Str("display", d.Name()).Msg(d.backendName() + " exited") |
| 222 | } else { | 236 | } else { | ||
| 223 | - | d.opts.Log.Info().Str("display", d.Name()).Msg("xwayland-satellite exited") | 237 | + | d.opts.Log.Info().Str("display", d.Name()).Msg(d.backendName() + " exited") |
| 224 | } | 238 | } | ||
| 225 | // Clients the satellite never accepted would wake us at once: a | 239 | // Clients the satellite never accepted would wake us at once: a | ||
| 226 | // satellite that cannot start must not respawn in a loop. | 240 | // satellite that cannot start must not respawn in a loop. | ||
| @@ -261,8 +275,14 @@ func (d *Display) waitClient(ctx context.Context, wake int) error { | |||||
| 261 | return ctx.Err() | 275 | return ctx.Err() | ||
| 262 | } | 276 | } | ||
| 263 | 277 | ||||
| 264 | - | // serve runs the satellite on the sockets until it exits or ctx ends. | 278 | + | // starter runs a backend on the reserved sockets until it exits or ctx ends. |
| 265 | - | func (d *Display) serve(ctx context.Context, stderr *logWriter) error { | 279 | + | type starter interface { |
| 280 | + | start(ctx context.Context, d *Display, stderr *logWriter) error | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | type satelliteStarter struct{} | |||
| 284 | + | ||||
| 285 | + | func (satelliteStarter) start(ctx context.Context, d *Display, stderr *logWriter) error { | |||
| 266 | args := []string{d.Name()} | 286 | args := []string{d.Name()} | ||
| 267 | for i := range d.sockets { | 287 | for i := range d.sockets { | ||
| 268 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | 288 | args = append(args, "-listenfd", strconv.Itoa(3+i)) | ||
| @@ -318,10 +338,11 @@ func (d *Display) dropPending() { | |||||
| 318 | // logWriter logs the satellite's stderr line by line and keeps the last | 338 | // logWriter logs the satellite's stderr line by line and keeps the last | ||
| 319 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | 339 | // lines for the exit log; exec copies it from a pipe on its own goroutine. | ||
| 320 | type logWriter struct { | 340 | type logWriter struct { | ||
| 321 | - | mu sync.Mutex | 341 | + | backend string |
| 322 | - | log zerowrap.Logger | 342 | + | mu sync.Mutex |
| 323 | - | line []byte | 343 | + | log zerowrap.Logger |
| 324 | - | last []string | 344 | + | line []byte |
| 345 | + | last []string | |||
| 325 | } | 346 | } | ||
| 326 | 347 | ||||
| 327 | // tail is the last lines written. | 348 | // tail is the last lines written. | ||
| @@ -342,7 +363,7 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 342 | } | 363 | } | ||
| 343 | if i > 0 { | 364 | if i > 0 { | ||
| 344 | line := string(w.line[:i]) | 365 | line := string(w.line[:i]) | ||
| 345 | - | w.log.Debug().Str("line", line).Msg("xwayland-satellite") | 366 | + | w.log.Debug().Str("line", line).Msg(w.backend) |
| 346 | if w.last = append(w.last, line); len(w.last) > 5 { | 367 | if w.last = append(w.last, line); len(w.last) > 5 { | ||
| 347 | w.last = w.last[1:] | 368 | w.last = w.last[1:] | ||
| 348 | } | 369 | } | ||
| @@ -354,3 +375,10 @@ func (w *logWriter) Write(p []byte) (int, error) { | |||||
| 354 | } | 375 | } | ||
| 355 | return len(p), nil | 376 | return len(p), nil | ||
| 356 | } | 377 | } | ||
| 378 | + | ||||
| 379 | + | func (d *Display) backendName() string { | |||
| 380 | + | if d.opts.Backend == ports.XwaylandBuiltin { | |||
| 381 | + | return "Xwayland" | |||
| 382 | + | } | |||
| 383 | + | return "xwayland-satellite" | |||
| 384 | + | } | |||
M internal/adapters/xwayland/xwayland_test.go +207 -0
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/neferwl/internal/adapters/logging" | 13 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | "github.com/stretchr/testify/mock" | |||
| 14 | "golang.org/x/sys/unix" | 16 | "golang.org/x/sys/unix" | ||
| 15 | ) | 17 | ) | ||
| 16 | 18 | ||||
| @@ -270,3 +272,208 @@ func TestRunKillsSatelliteGroup(t *testing.T) { | |||||
| 270 | } | 272 | } | ||
| 271 | } | 273 | } | ||
| 272 | } | 274 | } | ||
| 275 | + | ||||
| 276 | + | func TestStarterSelection(t *testing.T) { | |||
| 277 | + | for _, backend := range []ports.XwaylandBackend{ports.XwaylandSatellite, ports.XwaylandBuiltin} { | |||
| 278 | + | opts := testOptions(t, "true") | |||
| 279 | + | opts.Backend = backend | |||
| 280 | + | d, err := Open(opts) | |||
| 281 | + | if err != nil { | |||
| 282 | + | t.Fatal(err) | |||
| 283 | + | } | |||
| 284 | + | defer d.Close() | |||
| 285 | + | switch d.starter.(type) { | |||
| 286 | + | case satelliteStarter: | |||
| 287 | + | if backend != ports.XwaylandSatellite { | |||
| 288 | + | t.Fatal("satellite selected") | |||
| 289 | + | } | |||
| 290 | + | case builtinStarter: | |||
| 291 | + | if backend != ports.XwaylandBuiltin { | |||
| 292 | + | t.Fatal("builtin selected") | |||
| 293 | + | } | |||
| 294 | + | default: | |||
| 295 | + | t.Fatalf("unexpected starter %T", d.starter) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestRunCallsStarter(t *testing.T) { | |||
| 301 | + | d, err := Open(testOptions(t, "unused")) | |||
| 302 | + | if err != nil { | |||
| 303 | + | t.Fatal(err) | |||
| 304 | + | } | |||
| 305 | + | defer d.Close() | |||
| 306 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 307 | + | defer cancel() | |||
| 308 | + | m := newMockstarter(t) | |||
| 309 | + | d.starter = m | |||
| 310 | + | m.EXPECT().start(ctx, d, mock.Anything).RunAndReturn(func(_ context.Context, got *Display, stderr *logWriter) error { | |||
| 311 | + | if got != d || stderr == nil { | |||
| 312 | + | t.Error("starter arguments") | |||
| 313 | + | } | |||
| 314 | + | cancel() | |||
| 315 | + | return ctx.Err() | |||
| 316 | + | }).Once() | |||
| 317 | + | done := make(chan error, 1) | |||
| 318 | + | go func() { done <- d.Run(ctx) }() | |||
| 319 | + | unix.Close(dial(t, d.socket)) | |||
| 320 | + | select { | |||
| 321 | + | case err := <-done: | |||
| 322 | + | if err != nil { | |||
| 323 | + | t.Fatal(err) | |||
| 324 | + | } | |||
| 325 | + | case <-time.After(3 * time.Second): | |||
| 326 | + | t.Fatal("Run did not stop") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func TestWaitReady(t *testing.T) { | |||
| 331 | + | for _, tt := range []struct { | |||
| 332 | + | name, data string | |||
| 333 | + | wantErr bool | |||
| 334 | + | }{ | |||
| 335 | + | {"ready", "12\n", false}, {"invalid", "bad\n", true}, {"closed", "", true}, | |||
| 336 | + | } { | |||
| 337 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 338 | + | var p [2]int | |||
| 339 | + | if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil { | |||
| 340 | + | t.Fatal(err) | |||
| 341 | + | } | |||
| 342 | + | defer unix.Close(p[0]) | |||
| 343 | + | if tt.data != "" { | |||
| 344 | + | if _, err := unix.Write(p[1], []byte(tt.data)); err != nil { | |||
| 345 | + | t.Fatal(err) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | unix.Close(p[1]) | |||
| 349 | + | joined, err := waitReady(t.Context(), p[0], make(chan error)) | |||
| 350 | + | if joined || (err != nil) != tt.wantErr { | |||
| 351 | + | t.Fatalf("joined=%v err=%v", joined, err) | |||
| 352 | + | } | |||
| 353 | + | }) | |||
| 354 | + | } | |||
| 355 | + | t.Run("exit", func(t *testing.T) { | |||
| 356 | + | done := make(chan error, 1) | |||
| 357 | + | done <- errors.New("exit") | |||
| 358 | + | joined, err := waitReady(t.Context(), -1, done) | |||
| 359 | + | if !joined || err == nil { | |||
| 360 | + | t.Fatal(joined, err) | |||
| 361 | + | } | |||
| 362 | + | }) | |||
| 363 | + | t.Run("cancel", func(t *testing.T) { | |||
| 364 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 365 | + | cancel() | |||
| 366 | + | joined, err := waitReady(ctx, -1, make(chan error)) | |||
| 367 | + | if joined || !errors.Is(err, context.Canceled) { | |||
| 368 | + | t.Fatal(joined, err) | |||
| 369 | + | } | |||
| 370 | + | }) | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // readyScript stands in for Xwayland: after a short delay it creates a marker | |||
| 374 | + | // file and reports readiness on the -displayfd descriptor when ready is true, | |||
| 375 | + | // then runs tail. | |||
| 376 | + | func readyScript(t *testing.T, dir string, ready bool, tail string) (bin, marker string) { | |||
| 377 | + | t.Helper() | |||
| 378 | + | bin, marker = filepath.Join(dir, "xwayland"), filepath.Join(dir, "ready") | |||
| 379 | + | script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = -displayfd ] && fd=$2; shift; done\n" + | |||
| 380 | + | "sleep 0.3\n" | |||
| 381 | + | if ready { | |||
| 382 | + | script += ": > " + marker + "\neval \"echo 1 >&$fd\"\n" | |||
| 383 | + | } | |||
| 384 | + | script += tail + "\n" | |||
| 385 | + | if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { | |||
| 386 | + | t.Fatal(err) | |||
| 387 | + | } | |||
| 388 | + | return bin, marker | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | func runBuiltin(t *testing.T, ctx context.Context, ready bool, tail string, attachWM func(ctx context.Context, wmFD int)) (events []string) { | |||
| 392 | + | t.Helper() | |||
| 393 | + | opts := testOptions(t, "") | |||
| 394 | + | opts.Backend = ports.XwaylandBuiltin | |||
| 395 | + | var marker string | |||
| 396 | + | opts.Binary, marker = readyScript(t, opts.TmpDir, ready, tail) | |||
| 397 | + | exists := func() bool { _, err := os.Stat(marker); return err == nil } | |||
| 398 | + | opts.Attach = func(wlFD int) { | |||
| 399 | + | if exists() { | |||
| 400 | + | t.Error("Attach ran after readiness") | |||
| 401 | + | } | |||
| 402 | + | events = append(events, "attach") | |||
| 403 | + | unix.Close(wlFD) | |||
| 404 | + | } | |||
| 405 | + | opts.AttachWM = func(ctx context.Context, wmFD int) func() { | |||
| 406 | + | if !exists() { | |||
| 407 | + | t.Error("AttachWM ran before readiness") | |||
| 408 | + | } | |||
| 409 | + | events = append(events, "attachWM") | |||
| 410 | + | attachWM(ctx, wmFD) | |||
| 411 | + | return func() { events = append(events, "stop") } | |||
| 412 | + | } | |||
| 413 | + | d, err := Open(opts) | |||
| 414 | + | if err != nil { | |||
| 415 | + | t.Fatal(err) | |||
| 416 | + | } | |||
| 417 | + | defer d.Close() | |||
| 418 | + | _ = d.starter.start(ctx, d, &logWriter{log: opts.Log, backend: d.backendName()}) | |||
| 419 | + | return append(events, "returned") | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // AttachWM runs only after -displayfd readiness; stop runs before start | |||
| 423 | + | // returns, whether the process exits or the context ends. | |||
| 424 | + | func TestBuiltinAttachWMAfterReadiness(t *testing.T) { | |||
| 425 | + | for name, tc := range map[string]struct { | |||
| 426 | + | tail string | |||
| 427 | + | cancel bool | |||
| 428 | + | }{"process exit": {tail: "exit 0"}, "context end": {tail: "exec sleep 30", cancel: true}} { | |||
| 429 | + | t.Run(name, func(t *testing.T) { | |||
| 430 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 431 | + | defer cancel() | |||
| 432 | + | events := runBuiltin(t, ctx, true, tc.tail, func(_ context.Context, wmFD int) { | |||
| 433 | + | if _, err := unix.FcntlInt(uintptr(wmFD), unix.F_GETFD, 0); err != nil { | |||
| 434 | + | t.Errorf("wmFD not open: %v", err) | |||
| 435 | + | } | |||
| 436 | + | unix.Close(wmFD) | |||
| 437 | + | if tc.cancel { | |||
| 438 | + | go func() { time.Sleep(200 * time.Millisecond); cancel() }() | |||
| 439 | + | } | |||
| 440 | + | }) | |||
| 441 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |||
| 442 | + | t.Fatalf("events %s", got) | |||
| 443 | + | } | |||
| 444 | + | }) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | // A process that exits before reporting readiness never reaches AttachWM. | |||
| 449 | + | func TestBuiltinNoAttachWMWithoutReadiness(t *testing.T) { | |||
| 450 | + | events := runBuiltin(t, t.Context(), false, "exit 1", func(context.Context, int) {}) | |||
| 451 | + | if got := strings.Join(events, ","); got != "attach,returned" { | |||
| 452 | + | t.Fatalf("events %s", got) | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | // The AttachWM context ends when the process dies, so a bootstrap against a | |||
| 457 | + | // dead Xwayland gives up at once instead of waiting out its handshake. | |||
| 458 | + | func TestBuiltinAttachWMContextEndsWithProcess(t *testing.T) { | |||
| 459 | + | ended := make(chan error, 1) | |||
| 460 | + | start := time.Now() | |||
| 461 | + | events := runBuiltin(t, t.Context(), true, "sleep 0.3", func(ctx context.Context, wmFD int) { | |||
| 462 | + | defer unix.Close(wmFD) | |||
| 463 | + | select { | |||
| 464 | + | case <-ctx.Done(): | |||
| 465 | + | ended <- ctx.Err() | |||
| 466 | + | case <-time.After(5 * time.Second): | |||
| 467 | + | ended <- nil | |||
| 468 | + | } | |||
| 469 | + | }) | |||
| 470 | + | if err := <-ended; err == nil { | |||
| 471 | + | t.Fatal("AttachWM context did not end with the process") | |||
| 472 | + | } | |||
| 473 | + | if time.Since(start) > 3*time.Second { | |||
| 474 | + | t.Fatalf("took %v", time.Since(start)) | |||
| 475 | + | } | |||
| 476 | + | if got := strings.Join(events, ","); got != "attach,attachWM,stop,returned" { | |||
| 477 | + | t.Fatalf("events %s", got) | |||
| 478 | + | } | |||
| 479 | + | } | |||
M internal/app/app.go +13 -6
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | + | "os/exec" | |||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/neferwl/internal/adapters/logging" | 11 | "github.com/bnema/neferwl/internal/adapters/logging" | ||
| @@ -188,18 +189,24 @@ func logScenes(log zerowrap.Logger, set []ports.Scene) { | |||||
| 188 | } | 189 | } | ||
| 189 | } | 190 | } | ||
| 190 | 191 | ||||
| 191 | - | // openXwayland reserves an X11 display for xwayland-satellite, or returns | 192 | + | // openXwayland reserves an X11 display for the selected backend, or returns |
| 192 | // nil when X11 is off or unavailable: the session runs without it. | 193 | // nil when X11 is off or unavailable: the session runs without it. | ||
| 193 | - | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger) *xwayland.Display { | 194 | + | func openXwayland(ctx context.Context, opts Options, env []string, log zerowrap.Logger, attach func(int), attachWM func(context.Context, int) func()) *xwayland.Display { |
| 194 | - | bin := opts.Config.Xwayland | 195 | + | cfg := opts.Config.Xwayland |
| 195 | - | if bin == "" || opts.NoXwayland { | 196 | + | bin := cfg.Binary |
| 197 | + | if cfg.Backend == ports.XwaylandOff || opts.NoXwayland { | |||
| 196 | return nil | 198 | return nil | ||
| 197 | } | 199 | } | ||
| 198 | - | if !xwayland.Supported(ctx, bin) { | 200 | + | if cfg.Backend == ports.XwaylandBuiltin { |
| 201 | + | if _, err := exec.LookPath(bin); err != nil { | |||
| 202 | + | log.Warn().Err(err).Str("binary", bin).Msg("Xwayland not found: X11 apps disabled") | |||
| 203 | + | return nil | |||
| 204 | + | } | |||
| 205 | + | } else if !xwayland.Supported(ctx, bin) { | |||
| 199 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | 206 | log.Warn().Str("binary", bin).Msg("xwayland-satellite 0.7 or later not found: X11 apps disabled") | ||
| 200 | return nil | 207 | return nil | ||
| 201 | } | 208 | } | ||
| 202 | - | d, err := xwayland.Open(xwayland.Options{Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) | 209 | + | d, err := xwayland.Open(xwayland.Options{Backend: cfg.Backend, Attach: attach, AttachWM: attachWM, Binary: bin, Env: env, Dir: "/tmp/.X11-unix", TmpDir: "/tmp", Abstract: true, Log: log}) |
| 203 | if err != nil { | 210 | if err != nil { | ||
| 204 | log.Warn().Err(err).Msg("X11 display disabled") | 211 | log.Warn().Err(err).Msg("X11 display disabled") | ||
| 205 | return nil | 212 | return nil | ||
M internal/app/cursor.go +18 -0
| @@ -138,3 +138,21 @@ func loadCursorUpright(c ports.CursorChange, scale float64, limit int) (ports.Cu | |||||
| 138 | themeCursors.m[key] = out | 138 | themeCursors.m[key] = out | ||
| 139 | return out, nil | 139 | return out, nil | ||
| 140 | } | 140 | } | ||
| 141 | + | ||||
| 142 | + | // xwaylandSettings is what the built-in XWM publishes to X11 clients: the | |||
| 143 | + | // XSETTINGS cursor theme and size and the root cursor image, all from the | |||
| 144 | + | // XCURSOR_* environment. The DPI follows the layout inside the XWM. | |||
| 145 | + | func xwaylandSettings(cfg ports.XwaylandConfig) ports.XwaylandSettings { | |||
| 146 | + | if cfg.Backend != ports.XwaylandBuiltin { | |||
| 147 | + | return ports.XwaylandSettings{} | |||
| 148 | + | } | |||
| 149 | + | size, theme := cursorSize(), os.Getenv("XCURSOR_THEME") | |||
| 150 | + | st := ports.XwaylandSettings{XSettings: cfg.XSettings, CursorTheme: theme, CursorSize: size, RootCursor: true} | |||
| 151 | + | img, err := xcursor.Load(xcursor.SearchPath(), theme, []string{"default", "left_ptr"}, size) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return st // the XWM falls back to the X cursor font | |||
| 154 | + | } | |||
| 155 | + | img = img.ForSize(size, 64) | |||
| 156 | + | st.Cursor = ports.CursorImage{W: img.W, H: img.H, HotX: img.HotX, HotY: img.HotY, Pixels: img.Pixels} | |||
| 157 | + | return st | |||
| 158 | + | } | |||
M internal/app/session.go +2 -1
| @@ -140,6 +140,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 140 | RepeatRate: opts.Config.Keyboard.RepeatRate, | 140 | RepeatRate: opts.Config.Keyboard.RepeatRate, | ||
| 141 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | 141 | RepeatDelay: opts.Config.Keyboard.RepeatDelay, | ||
| 142 | LogDamage: logging.Enabled(ctx, "wayland-damage"), | 142 | LogDamage: logging.Enabled(ctx, "wayland-damage"), | ||
| 143 | + | Xwayland: xwaylandSettings(opts.Config.Xwayland), | |||
| 143 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | 144 | }, s.p.wayland(), logging.For(ctx, "wayland")); err != nil { | ||
| 144 | return s, err | 145 | return s, err | ||
| 145 | } | 146 | } | ||
| @@ -152,7 +153,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 152 | s.statePath = statePath | 153 | s.statePath = statePath | ||
| 153 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | 154 | s.childEnv = append(s.childEnv, statefile.Env+"="+statePath) | ||
| 154 | } | 155 | } | ||
| 155 | - | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland")); s.xdisplay != nil { | 156 | + | if s.xdisplay = openXwayland(ctx, opts, s.childEnv, logging.For(ctx, "xwayland"), s.server.AttachXwayland, s.server.AttachXWM); s.xdisplay != nil { |
| 156 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | 157 | s.childEnv = append(s.childEnv, "DISPLAY="+s.xdisplay.Name()) | ||
| 157 | } | 158 | } | ||
| 158 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | 159 | s.child = launcher.New(s.childEnv, s.security, logging.For(ctx, "launcher")) | ||
A internal/app/testdata/x11probe/main.go +692 -0
| @@ -0,0 +1,692 @@ | |||||
| 1 | + | // Command x11probe is a minimal X11 client for the Xwayland end-to-end test. | |||
| 2 | + | // | |||
| 3 | + | // It opens one window filled with solid green, prints "mapped WxH" after the | |||
| 4 | + | // first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the | |||
| 5 | + | // window manager sends WM_DELETE_WINDOW. | |||
| 6 | + | // | |||
| 7 | + | // With -override it also maps a magenta override-redirect window after the | |||
| 8 | + | // main one is mapped (prints "override mapped") and unmaps it after | |||
| 9 | + | // -override-for (prints "override unmapped"). With -override-dismiss-on-focusout | |||
| 10 | + | // it unmaps it instead when the main window loses focus (prints "dismissed"). | |||
| 11 | + | // | |||
| 12 | + | // With -initial-fullscreen the window maps already fullscreen and fixed-size. | |||
| 13 | + | // With -fullscreen it asks the window manager for _NET_WM_STATE_FULLSCREEN | |||
| 14 | + | // -fullscreen-delay after the window is mapped (0: right at the map) and | |||
| 15 | + | // prints "fullscreen WxH" when the window is resized; -fullscreen-off asks to | |||
| 16 | + | // leave it that long after (prints "fullscreen off WxH"). -keyboard-grab | |||
| 17 | + | // calls XGrabKeyboard on the window once it is mapped (prints "grab"). | |||
| 18 | + | // -at X,Y creates the window at that root position. | |||
| 19 | + | // | |||
| 20 | + | // Four more modes run without a window and exit when done. | |||
| 21 | + | // -copy TEXT|@file owns -copy-sel (CLIPBOARD or PRIMARY) for | |||
| 22 | + | // -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above | |||
| 23 | + | // 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR | |||
| 24 | + | // aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or | |||
| 25 | + | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, Gdk/WindowScalingFactor, Gdk/UnscaledDPI, | |||
| 26 | + | // Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none". | |||
| 27 | + | // -root-cursor prints "cursor WxH" of the XFixes cursor image. | |||
| 28 | + | package main | |||
| 29 | + | ||||
| 30 | + | import ( | |||
| 31 | + | "crypto/sha256" | |||
| 32 | + | "encoding/binary" | |||
| 33 | + | "flag" | |||
| 34 | + | "fmt" | |||
| 35 | + | "os" | |||
| 36 | + | "strconv" | |||
| 37 | + | "strings" | |||
| 38 | + | "time" | |||
| 39 | + | ||||
| 40 | + | "golang.org/x/sys/unix" | |||
| 41 | + | ||||
| 42 | + | "bnema.dev/xturbo" | |||
| 43 | + | "bnema.dev/xturbo/shape" | |||
| 44 | + | "bnema.dev/xturbo/xfixes" | |||
| 45 | + | "bnema.dev/xturbo/xproto" | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 48 | + | const ( | |||
| 49 | + | green = 0x00ff00 | |||
| 50 | + | magenta = 0xff00ff | |||
| 51 | + | ) | |||
| 52 | + | ||||
| 53 | + | func main() { | |||
| 54 | + | if err := run(); err != nil { | |||
| 55 | + | fmt.Fprintln(os.Stderr, "x11probe:", err) | |||
| 56 | + | os.Exit(1) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func run() error { | |||
| 61 | + | class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)") | |||
| 62 | + | title := flag.String("title", "probe", "_NET_WM_NAME") | |||
| 63 | + | size := flag.String("size", "300x200", "window size WxH") | |||
| 64 | + | sleep := flag.Duration("sleep", 0, "wait before mapping the window") | |||
| 65 | + | override := flag.Bool("override", false, "map a magenta override-redirect window after the main one") | |||
| 66 | + | overrideAt := flag.String("override-at", "100,50", "override window position X,Y in root coordinates") | |||
| 67 | + | overrideSize := flag.String("override-size", "120x80", "override window size WxH") | |||
| 68 | + | overrideFor := flag.Duration("override-for", 0, "unmap the override window after this delay (0: never)") | |||
| 69 | + | overrideType := flag.String("override-type", "menu", "override window type: menu or tooltip") | |||
| 70 | + | overrideShape := flag.Bool("override-shape", false, "give the override window an empty input shape") | |||
| 71 | + | dismiss := flag.Bool("override-dismiss-on-focusout", false, "unmap the override window when the main window loses focus") | |||
| 72 | + | fullscreen := flag.Bool("fullscreen", false, "request _NET_WM_STATE_FULLSCREEN after the window is mapped") | |||
| 73 | + | fullscreenDelay := flag.Duration("fullscreen-delay", 0, "delay between the map and the fullscreen request") | |||
| 74 | + | fullscreenOff := flag.Duration("fullscreen-off", 0, "leave fullscreen this long after the request (0: never)") | |||
| 75 | + | initialFullscreen := flag.Bool("initial-fullscreen", false, "set _NET_WM_STATE_FULLSCREEN and fixed-size WM_NORMAL_HINTS (min=max=size) before mapping") | |||
| 76 | + | keyboardGrab := flag.Bool("keyboard-grab", false, "XGrabKeyboard on the window once it is mapped") | |||
| 77 | + | at := flag.String("at", "0,0", "window position X,Y in root coordinates") | |||
| 78 | + | copyText := flag.String("copy", "", "own a selection with TEXT (or @file) and serve it, no window") | |||
| 79 | + | copySel := flag.String("copy-sel", "CLIPBOARD", "selection for -copy: CLIPBOARD or PRIMARY") | |||
| 80 | + | copyFor := flag.Duration("copy-for", 5*time.Second, "how long -copy serves the selection") | |||
| 81 | + | paste := flag.String("paste", "", "print the text of selection CLIPBOARD or PRIMARY, no window") | |||
| 82 | + | xsettings := flag.Bool("xsettings", false, "print the XSETTINGS values, no window") | |||
| 83 | + | rootCursor := flag.Bool("root-cursor", false, "print the size of the cursor shown over the root, no window") | |||
| 84 | + | flag.Parse() | |||
| 85 | + | if *copyText != "" || *paste != "" || *xsettings || *rootCursor { | |||
| 86 | + | return runTool(*copyText, *copySel, *copyFor, *paste, *xsettings, *rootCursor) | |||
| 87 | + | } | |||
| 88 | + | atX, atY, okAt := strings.Cut(*at, ",") | |||
| 89 | + | if !okAt { | |||
| 90 | + | return fmt.Errorf("bad -at %q", *at) | |||
| 91 | + | } | |||
| 92 | + | w, h, ok := parseSize(*size) | |||
| 93 | + | if !ok { | |||
| 94 | + | return fmt.Errorf("bad -size %q", *size) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | c, err := xturbo.Dial("") // $DISPLAY | |||
| 98 | + | if err != nil { | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | defer c.Close() | |||
| 102 | + | ||||
| 103 | + | var atoms [9]uint32 | |||
| 104 | + | if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING", | |||
| 105 | + | "_NET_WM_WINDOW_TYPE", "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", "_NET_WM_WINDOW_TYPE_TOOLTIP", "_NET_WM_STATE", "_NET_WM_STATE_FULLSCREEN"}, false, atoms[:]); err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3]) | |||
| 109 | + | typeAtom, typeMenu, typeTooltip := xproto.Atom(atoms[4]), atoms[5], atoms[6] | |||
| 110 | + | netWMState, netWMFullscreen := atoms[7], atoms[8] | |||
| 111 | + | ||||
| 112 | + | b := c.Buf() | |||
| 113 | + | xwin, err := c.NewXID() | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | xgc, err := c.NewXID() | |||
| 118 | + | if err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | win, gc := xproto.Window(xwin), xproto.Gcontext(xgc) | |||
| 122 | + | ||||
| 123 | + | var v xproto.CreateWindowValues | |||
| 124 | + | v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange | xproto.EventMaskPropertyChange) | |||
| 125 | + | xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), int16(atoi(atX)), int16(atoi(atY)), uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v) | |||
| 126 | + | ||||
| 127 | + | wmClass := []byte("x11probe\x00" + *class + "\x00") | |||
| 128 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass) | |||
| 129 | + | name := []byte(*title) | |||
| 130 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name) | |||
| 131 | + | var del [4]byte | |||
| 132 | + | binary.LittleEndian.PutUint32(del[:], uint32(deleteWin)) | |||
| 133 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:]) | |||
| 134 | + | ||||
| 135 | + | var gv xproto.CreateGCValues | |||
| 136 | + | gv.SetForeground(green) | |||
| 137 | + | xproto.CreateGC(b, gc, xproto.Drawable(win), &gv) | |||
| 138 | + | if err := c.Flush(); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | time.Sleep(*sleep) | |||
| 142 | + | if *at != "0,0" { | |||
| 143 | + | // Like games, ask for the rect before the map (a ConfigureRequest the | |||
| 144 | + | // window manager sees as the output hint). | |||
| 145 | + | var cv xproto.ConfigureWindowValues | |||
| 146 | + | cv.SetX(int32(atoi(atX))).SetY(int32(atoi(atY))).SetWidth(uint32(w)).SetHeight(uint32(h)) | |||
| 147 | + | xproto.ConfigureWindow(b, win, &cv) | |||
| 148 | + | } | |||
| 149 | + | if *initialFullscreen { | |||
| 150 | + | // Like a Proton game: fullscreen state and a fixed size, both set | |||
| 151 | + | // before the map. | |||
| 152 | + | var st [4]byte | |||
| 153 | + | binary.LittleEndian.PutUint32(st[:], netWMFullscreen) | |||
| 154 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.Atom(netWMState), xproto.AtomAtom, 32, 1, st[:]) | |||
| 155 | + | var nh [72]byte // WM_SIZE_HINTS: flags, x, y, w, h, min, max, ... | |||
| 156 | + | binary.LittleEndian.PutUint32(nh[0:], 16|32) // PMinSize|PMaxSize | |||
| 157 | + | for _, off := range []int{20, 28} { | |||
| 158 | + | binary.LittleEndian.PutUint32(nh[off:], uint32(w)) | |||
| 159 | + | binary.LittleEndian.PutUint32(nh[off+4:], uint32(h)) | |||
| 160 | + | } | |||
| 161 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmNormalHints, xproto.AtomWmSizeHints, 32, 18, nh[:]) | |||
| 162 | + | } | |||
| 163 | + | xproto.MapWindow(b, win) | |||
| 164 | + | ||||
| 165 | + | // The override-redirect window is created once the main window is mapped. | |||
| 166 | + | var ovWin xproto.Window | |||
| 167 | + | var ovAt, ovSz [2]int | |||
| 168 | + | if *override { | |||
| 169 | + | var ok1, ok2 bool | |||
| 170 | + | x, y, found := strings.Cut(*overrideAt, ",") | |||
| 171 | + | ovAt[0], ovAt[1] = atoi(x), atoi(y) | |||
| 172 | + | ok1 = found | |||
| 173 | + | ovSz[0], ovSz[1], ok2 = parseSize(*overrideSize) | |||
| 174 | + | if !ok1 || !ok2 || (*overrideType != "menu" && *overrideType != "tooltip") { | |||
| 175 | + | return fmt.Errorf("bad -override-at/-override-size/-override-type") | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | var ovDeadline time.Time // zero: no timed unmap pending | |||
| 179 | + | unmapOverride := func(msg string) { | |||
| 180 | + | if ovWin == 0 { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | xproto.UnmapWindow(b, ovWin) | |||
| 184 | + | ovWin = 0 | |||
| 185 | + | ovDeadline = time.Time{} | |||
| 186 | + | fmt.Println(msg) | |||
| 187 | + | } | |||
| 188 | + | mapOverride := func() error { | |||
| 189 | + | id, err := c.NewXID() | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | ovWin = xproto.Window(id) | |||
| 194 | + | var ov xproto.CreateWindowValues | |||
| 195 | + | ov.SetBackgroundPixel(magenta).SetOverrideRedirect(true) | |||
| 196 | + | xproto.CreateWindow(b, 0, ovWin, xproto.Window(c.Root()), int16(ovAt[0]), int16(ovAt[1]), uint16(ovSz[0]), uint16(ovSz[1]), 0, xproto.WindowClassInputOutput, 0, &ov) | |||
| 197 | + | typ := typeMenu | |||
| 198 | + | if *overrideType == "tooltip" { | |||
| 199 | + | typ = typeTooltip | |||
| 200 | + | } | |||
| 201 | + | var tb [4]byte | |||
| 202 | + | binary.LittleEndian.PutUint32(tb[:], typ) | |||
| 203 | + | xproto.ChangeProperty(b, xproto.PropModeReplace, ovWin, typeAtom, xproto.AtomAtom, 32, 1, tb[:]) | |||
| 204 | + | if *overrideShape { | |||
| 205 | + | shape.Rectangles(b, shape.SoSet, shape.SkInput, 0, ovWin, 0, 0, nil) | |||
| 206 | + | } | |||
| 207 | + | xproto.MapWindow(b, ovWin) | |||
| 208 | + | fmt.Println("override mapped") | |||
| 209 | + | if *overrideFor > 0 { | |||
| 210 | + | ovDeadline = time.Now().Add(*overrideFor) | |||
| 211 | + | } | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | var ovErr error | |||
| 215 | + | ||||
| 216 | + | // Fullscreen requests go to the root window as a client message, as ICCCM | |||
| 217 | + | // and EWMH say for a mapped window. | |||
| 218 | + | fsMsg := func(action uint32) { | |||
| 219 | + | var ev [32]byte | |||
| 220 | + | ev[0] = byte(xproto.KindClientMessage) | |||
| 221 | + | ev[1] = 32 | |||
| 222 | + | binary.LittleEndian.PutUint32(ev[4:], uint32(win)) | |||
| 223 | + | binary.LittleEndian.PutUint32(ev[8:], netWMState) | |||
| 224 | + | binary.LittleEndian.PutUint32(ev[12:], action) | |||
| 225 | + | binary.LittleEndian.PutUint32(ev[16:], netWMFullscreen) | |||
| 226 | + | xproto.SendEvent(b, false, xproto.Window(c.Root()), xproto.EventMaskSubstructureRedirect|xproto.EventMaskSubstructureNotify, &ev) | |||
| 227 | + | } | |||
| 228 | + | var fsAt, fsOffAt time.Time // zero: nothing pending | |||
| 229 | + | ||||
| 230 | + | mapped, done, isFS := false, false, false | |||
| 231 | + | cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify | |||
| 232 | + | hd := &xturbo.Handler{ | |||
| 233 | + | OnEvent: func(ev *xturbo.Event) { | |||
| 234 | + | switch ev.Kind { | |||
| 235 | + | case xproto.KindExpose: | |||
| 236 | + | xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}}) | |||
| 237 | + | case xproto.KindConfigureNotify: | |||
| 238 | + | e := xproto.ConfigureNotifyOf(ev) | |||
| 239 | + | if e.Window() != win { | |||
| 240 | + | break | |||
| 241 | + | } | |||
| 242 | + | cw, ch = e.Width(), e.Height() | |||
| 243 | + | if !mapped { | |||
| 244 | + | mapped = true | |||
| 245 | + | fmt.Printf("mapped %dx%d\n", cw, ch) | |||
| 246 | + | if *override { | |||
| 247 | + | ovErr = mapOverride() | |||
| 248 | + | } | |||
| 249 | + | if *fullscreen { | |||
| 250 | + | fsAt = time.Now().Add(*fullscreenDelay) | |||
| 251 | + | } | |||
| 252 | + | if *keyboardGrab { | |||
| 253 | + | xproto.GrabKeyboard(b, true, win, 0, xproto.GrabModeAsync, xproto.GrabModeAsync) | |||
| 254 | + | fmt.Println("grab") | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | case xproto.KindPropertyNotify: | |||
| 258 | + | if e := xproto.PropertyNotifyOf(ev); e.Window() == win && xproto.Atom(e.Atom()) == xproto.Atom(netWMState) { | |||
| 259 | + | seq := xproto.GetProperty(b, false, win, xproto.Atom(netWMState), xproto.AtomAtom, 0, 64) | |||
| 260 | + | if err := c.Expect(seq, 16, 0, 0, 0, 0); err != nil { | |||
| 261 | + | ovErr = err | |||
| 262 | + | } | |||
| 263 | + | } | |||
| 264 | + | case xproto.KindFocusIn: | |||
| 265 | + | fmt.Println("focus") | |||
| 266 | + | case xproto.KindFocusOut: | |||
| 267 | + | if *dismiss && xproto.FocusOutOf(ev).Event() == win { | |||
| 268 | + | unmapOverride("dismissed") | |||
| 269 | + | } | |||
| 270 | + | case xproto.KindClientMessage: | |||
| 271 | + | e := xproto.ClientMessageOf(ev) | |||
| 272 | + | if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) { | |||
| 273 | + | done = true | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | }, | |||
| 277 | + | OnReply: func(r *xturbo.Reply) { | |||
| 278 | + | rep, ok := xproto.ParseGetPropertyReply(r.Raw) | |||
| 279 | + | if !ok { | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | on := false | |||
| 283 | + | for v := rep.Value(); len(v) >= 4; v = v[4:] { | |||
| 284 | + | on = on || binary.LittleEndian.Uint32(v) == netWMFullscreen | |||
| 285 | + | } | |||
| 286 | + | // Report the state the window manager published, once per change. | |||
| 287 | + | switch { | |||
| 288 | + | case on && !isFS: | |||
| 289 | + | fmt.Printf("fullscreen %dx%d\n", cw, ch) | |||
| 290 | + | case !on && isFS: | |||
| 291 | + | fmt.Printf("fullscreen off %dx%d\n", cw, ch) | |||
| 292 | + | } | |||
| 293 | + | isFS = on | |||
| 294 | + | }, | |||
| 295 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |||
| 296 | + | } | |||
| 297 | + | for !done { | |||
| 298 | + | events := int16(unix.POLLIN) | |||
| 299 | + | if err := c.Flush(); err == xturbo.ErrWouldBlock { | |||
| 300 | + | events |= unix.POLLOUT | |||
| 301 | + | } else if err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | timeout := -1 | |||
| 305 | + | for _, d := range []time.Time{ovDeadline, fsAt, fsOffAt} { | |||
| 306 | + | if !d.IsZero() { | |||
| 307 | + | ms := max(int(time.Until(d).Milliseconds())+1, 0) | |||
| 308 | + | if timeout < 0 || ms < timeout { | |||
| 309 | + | timeout = ms | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}} | |||
| 314 | + | if _, err := unix.Poll(fds, timeout); err != nil && err != unix.EINTR { | |||
| 315 | + | return err | |||
| 316 | + | } | |||
| 317 | + | if !ovDeadline.IsZero() && !time.Now().Before(ovDeadline) { | |||
| 318 | + | unmapOverride("override unmapped") | |||
| 319 | + | } | |||
| 320 | + | if !fsAt.IsZero() && !time.Now().Before(fsAt) { | |||
| 321 | + | fsAt = time.Time{} | |||
| 322 | + | fsMsg(1) | |||
| 323 | + | if *fullscreenOff > 0 { | |||
| 324 | + | fsOffAt = time.Now().Add(*fullscreenOff) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | if !fsOffAt.IsZero() && !time.Now().Before(fsOffAt) { | |||
| 328 | + | fsOffAt = time.Time{} | |||
| 329 | + | fsMsg(0) | |||
| 330 | + | } | |||
| 331 | + | if err := c.Dispatch(hd); err != nil { | |||
| 332 | + | return err | |||
| 333 | + | } | |||
| 334 | + | if ovErr != nil { | |||
| 335 | + | return ovErr | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | return nil | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func parseSize(s string) (w, h int, ok bool) { | |||
| 342 | + | ws, hs, found := strings.Cut(s, "x") | |||
| 343 | + | if !found { | |||
| 344 | + | return 0, 0, false | |||
| 345 | + | } | |||
| 346 | + | w, err1 := strconv.Atoi(ws) | |||
| 347 | + | h, err2 := strconv.Atoi(hs) | |||
| 348 | + | return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func atoi(s string) int { | |||
| 352 | + | n, _ := strconv.Atoi(s) | |||
| 353 | + | return n | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // tool is the connection of the windowless modes. Events read while waiting | |||
| 357 | + | // for a reply are queued, so the code reads linearly. | |||
| 358 | + | type tool struct { | |||
| 359 | + | c *xturbo.Conn | |||
| 360 | + | b *xturbo.Buf | |||
| 361 | + | win xproto.Window | |||
| 362 | + | q []xturbo.Event | |||
| 363 | + | reply []byte | |||
| 364 | + | h xturbo.Handler | |||
| 365 | + | atoms map[string]xproto.Atom | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | const chunk = 65536 | |||
| 369 | + | ||||
| 370 | + | func newTool() (*tool, error) { | |||
| 371 | + | c, err := xturbo.Dial("") | |||
| 372 | + | if err != nil { | |||
| 373 | + | return nil, err | |||
| 374 | + | } | |||
| 375 | + | t := &tool{c: c, b: c.Buf(), atoms: map[string]xproto.Atom{}} | |||
| 376 | + | t.h = xturbo.Handler{ | |||
| 377 | + | OnEvent: func(ev *xturbo.Event) { t.q = append(t.q, *ev) }, | |||
| 378 | + | OnReply: func(r *xturbo.Reply) { t.reply = append(t.reply[:0], r.Raw...) }, | |||
| 379 | + | OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) }, | |||
| 380 | + | } | |||
| 381 | + | names := []string{"CLIPBOARD", "PRIMARY", "TARGETS", "UTF8_STRING", "STRING", "TEXT", "INCR", "_PROBE_SEL", | |||
| 382 | + | "_XSETTINGS_S0", "_XSETTINGS_SETTINGS"} | |||
| 383 | + | ids := make([]uint32, len(names)) | |||
| 384 | + | if err := c.InternAtoms(names, false, ids); err != nil { | |||
| 385 | + | return nil, err | |||
| 386 | + | } | |||
| 387 | + | for i, n := range names { | |||
| 388 | + | t.atoms[n] = xproto.Atom(ids[i]) | |||
| 389 | + | } | |||
| 390 | + | xid, err := c.NewXID() | |||
| 391 | + | if err != nil { | |||
| 392 | + | return nil, err | |||
| 393 | + | } | |||
| 394 | + | t.win = xproto.Window(xid) | |||
| 395 | + | var v xproto.CreateWindowValues | |||
| 396 | + | v.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 397 | + | xproto.CreateWindow(t.b, 0, t.win, xproto.Window(c.Root()), -10, -10, 1, 1, 0, xproto.WindowClassInputOnly, 0, &v) | |||
| 398 | + | return t, t.c.Sync(&t.h) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // rpc sends the queued requests and returns the reply of seq. | |||
| 402 | + | func (t *tool) rpc(seq xturbo.Seq) ([]byte, error) { | |||
| 403 | + | t.reply = t.reply[:0] | |||
| 404 | + | if err := t.c.Expect(seq, 16, 0, 0, 0, 0); err != nil { | |||
| 405 | + | return nil, err | |||
| 406 | + | } | |||
| 407 | + | if err := t.c.Sync(&t.h); err != nil { | |||
| 408 | + | return nil, err | |||
| 409 | + | } | |||
| 410 | + | return t.reply, nil | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | // next returns the next event, or false at the deadline. | |||
| 414 | + | func (t *tool) next(deadline time.Time) (xturbo.Event, bool, error) { | |||
| 415 | + | for len(t.q) == 0 { | |||
| 416 | + | events := int16(unix.POLLIN) | |||
| 417 | + | if err := t.c.Flush(); err == xturbo.ErrWouldBlock { | |||
| 418 | + | events |= unix.POLLOUT | |||
| 419 | + | } else if err != nil { | |||
| 420 | + | return xturbo.Event{}, false, err | |||
| 421 | + | } | |||
| 422 | + | ms := int(time.Until(deadline).Milliseconds()) + 1 | |||
| 423 | + | if ms <= 0 { | |||
| 424 | + | return xturbo.Event{}, false, nil | |||
| 425 | + | } | |||
| 426 | + | fds := []unix.PollFd{{Fd: int32(t.c.Fd()), Events: events}} | |||
| 427 | + | if _, err := unix.Poll(fds, ms); err != nil && err != unix.EINTR { | |||
| 428 | + | return xturbo.Event{}, false, err | |||
| 429 | + | } | |||
| 430 | + | if err := t.c.Dispatch(&t.h); err != nil { | |||
| 431 | + | return xturbo.Event{}, false, err | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ev := t.q[0] | |||
| 435 | + | t.q = t.q[1:] | |||
| 436 | + | return ev, true, nil | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | func (t *tool) selAtom(name string) (xproto.Atom, error) { | |||
| 440 | + | switch name { | |||
| 441 | + | case "CLIPBOARD", "PRIMARY": | |||
| 442 | + | return t.atoms[name], nil | |||
| 443 | + | } | |||
| 444 | + | return 0, fmt.Errorf("bad selection %q", name) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func runTool(copyText, copySel string, copyFor time.Duration, paste string, xsettings, rootCursor bool) error { | |||
| 448 | + | t, err := newTool() | |||
| 449 | + | if err != nil { | |||
| 450 | + | return err | |||
| 451 | + | } | |||
| 452 | + | defer t.c.Close() | |||
| 453 | + | switch { | |||
| 454 | + | case copyText != "": | |||
| 455 | + | return t.copyMode(copyText, copySel, copyFor) | |||
| 456 | + | case paste != "": | |||
| 457 | + | return t.pasteMode(paste) | |||
| 458 | + | case xsettings: | |||
| 459 | + | return t.xsettingsMode() | |||
| 460 | + | } | |||
| 461 | + | return t.cursorMode() | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func (t *tool) copyMode(text, selName string, dur time.Duration) error { | |||
| 465 | + | data := []byte(text) | |||
| 466 | + | if f, ok := strings.CutPrefix(text, "@"); ok { | |||
| 467 | + | var err error | |||
| 468 | + | if data, err = os.ReadFile(f); err != nil { | |||
| 469 | + | return err | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | sel, err := t.selAtom(selName) | |||
| 473 | + | if err != nil { | |||
| 474 | + | return err | |||
| 475 | + | } | |||
| 476 | + | xproto.SetSelectionOwner(t.b, t.win, sel, 0) | |||
| 477 | + | raw, err := t.rpc(xproto.GetSelectionOwner(t.b, sel)) | |||
| 478 | + | if err != nil { | |||
| 479 | + | return err | |||
| 480 | + | } | |||
| 481 | + | if r, ok := xproto.ParseGetSelectionOwnerReply(raw); !ok || r.Owner() != t.win { | |||
| 482 | + | return fmt.Errorf("selection %s not owned", selName) | |||
| 483 | + | } | |||
| 484 | + | fmt.Println("copy owned") | |||
| 485 | + | var ( | |||
| 486 | + | incrReq xproto.Window // requestor of the running INCR transfer | |||
| 487 | + | incrProp, incrTgt xproto.Atom | |||
| 488 | + | incrOff int | |||
| 489 | + | incrLive bool | |||
| 490 | + | notify = func(req xproto.Window, selection, target, prop xproto.Atom, time xproto.Timestamp) { | |||
| 491 | + | var ev [32]byte | |||
| 492 | + | d := xproto.SelectionNotifyEventData{Time: time, Requestor: req, Selection: selection, Target: target, Property: prop} | |||
| 493 | + | d.Put(&ev) | |||
| 494 | + | xproto.SendEvent(t.b, false, req, 0, &ev) | |||
| 495 | + | } | |||
| 496 | + | ) | |||
| 497 | + | deadline := time.Now().Add(dur) | |||
| 498 | + | for { | |||
| 499 | + | ev, ok, err := t.next(deadline) | |||
| 500 | + | if err != nil || !ok { | |||
| 501 | + | return err | |||
| 502 | + | } | |||
| 503 | + | switch ev.Kind { | |||
| 504 | + | case xproto.KindSelectionClear: | |||
| 505 | + | fmt.Println("copy lost") | |||
| 506 | + | return nil | |||
| 507 | + | case xproto.KindSelectionRequest: | |||
| 508 | + | e := xproto.SelectionRequestOf(&ev) | |||
| 509 | + | req, prop, tgt := e.Requestor(), e.Property(), e.Target() | |||
| 510 | + | if prop == 0 { | |||
| 511 | + | prop = tgt | |||
| 512 | + | } | |||
| 513 | + | switch tgt { | |||
| 514 | + | case t.atoms["TARGETS"]: | |||
| 515 | + | var l [16]byte | |||
| 516 | + | for i, n := range []string{"TARGETS", "UTF8_STRING", "STRING", "TEXT"} { | |||
| 517 | + | binary.LittleEndian.PutUint32(l[i*4:], uint32(t.atoms[n])) | |||
| 518 | + | } | |||
| 519 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, xproto.AtomAtom, 32, 4, l[:]) | |||
| 520 | + | case t.atoms["UTF8_STRING"], t.atoms["STRING"], t.atoms["TEXT"]: | |||
| 521 | + | if len(data) <= chunk { | |||
| 522 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, tgt, 8, uint32(len(data)), data) | |||
| 523 | + | break | |||
| 524 | + | } | |||
| 525 | + | var cv xproto.ChangeWindowAttributesValues | |||
| 526 | + | cv.SetEventMask(xproto.EventMaskPropertyChange) | |||
| 527 | + | xproto.ChangeWindowAttributes(t.b, req, &cv) | |||
| 528 | + | var n [4]byte | |||
| 529 | + | binary.LittleEndian.PutUint32(n[:], uint32(len(data))) | |||
| 530 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, req, prop, t.atoms["INCR"], 32, 1, n[:]) | |||
| 531 | + | incrReq, incrProp, incrTgt, incrOff, incrLive = req, prop, tgt, 0, true | |||
| 532 | + | default: | |||
| 533 | + | prop = 0 | |||
| 534 | + | } | |||
| 535 | + | notify(req, e.Selection(), tgt, prop, e.Time()) | |||
| 536 | + | case xproto.KindPropertyNotify: | |||
| 537 | + | e := xproto.PropertyNotifyOf(&ev) | |||
| 538 | + | if !incrLive || e.Window() != incrReq || e.Atom() != incrProp || e.State() != xproto.PropertyDelete { | |||
| 539 | + | break | |||
| 540 | + | } | |||
| 541 | + | end := min(incrOff+chunk, len(data)) | |||
| 542 | + | xproto.ChangeProperty(t.b, xproto.PropModeReplace, incrReq, incrProp, incrTgt, 8, uint32(end-incrOff), data[incrOff:end]) | |||
| 543 | + | if end == incrOff { | |||
| 544 | + | incrLive = false | |||
| 545 | + | } | |||
| 546 | + | incrOff = end | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func (t *tool) pasteMode(selName string) error { | |||
| 552 | + | sel, err := t.selAtom(selName) | |||
| 553 | + | if err != nil { | |||
| 554 | + | return err | |||
| 555 | + | } | |||
| 556 | + | prop := t.atoms["_PROBE_SEL"] | |||
| 557 | + | xproto.ConvertSelection(t.b, t.win, sel, t.atoms["UTF8_STRING"], prop, 0) | |||
| 558 | + | deadline := time.Now().Add(10 * time.Second) | |||
| 559 | + | var got []byte | |||
| 560 | + | incr := false | |||
| 561 | + | get := func() (value []byte, typ xproto.Atom, err error) { | |||
| 562 | + | raw, err := t.rpc(xproto.GetProperty(t.b, true, t.win, prop, xproto.AtomAny, 0, 1<<22)) | |||
| 563 | + | if err != nil { | |||
| 564 | + | return nil, 0, err | |||
| 565 | + | } | |||
| 566 | + | r, ok := xproto.ParseGetPropertyReply(raw) | |||
| 567 | + | if !ok { | |||
| 568 | + | return nil, 0, fmt.Errorf("bad GetProperty reply") | |||
| 569 | + | } | |||
| 570 | + | return append([]byte(nil), r.Value()...), r.Type(), nil | |||
| 571 | + | } | |||
| 572 | + | for { | |||
| 573 | + | ev, ok, err := t.next(deadline) | |||
| 574 | + | if err != nil { | |||
| 575 | + | return err | |||
| 576 | + | } | |||
| 577 | + | if !ok { | |||
| 578 | + | return fmt.Errorf("paste timed out") | |||
| 579 | + | } | |||
| 580 | + | switch ev.Kind { | |||
| 581 | + | case xproto.KindSelectionNotify: | |||
| 582 | + | if xproto.SelectionNotifyOf(&ev).Property() == 0 { | |||
| 583 | + | fmt.Println("paste none") | |||
| 584 | + | return nil | |||
| 585 | + | } | |||
| 586 | + | v, typ, err := get() | |||
| 587 | + | if err != nil { | |||
| 588 | + | return err | |||
| 589 | + | } | |||
| 590 | + | if typ != t.atoms["INCR"] { | |||
| 591 | + | return printPaste(v) | |||
| 592 | + | } | |||
| 593 | + | incr = true // reading and deleting the INCR property starts the transfer | |||
| 594 | + | case xproto.KindPropertyNotify: | |||
| 595 | + | e := xproto.PropertyNotifyOf(&ev) | |||
| 596 | + | if !incr || e.Window() != t.win || e.Atom() != prop || e.State() != xproto.PropertyNewValue { | |||
| 597 | + | break | |||
| 598 | + | } | |||
| 599 | + | v, _, err := get() | |||
| 600 | + | if err != nil { | |||
| 601 | + | return err | |||
| 602 | + | } | |||
| 603 | + | if len(v) == 0 { | |||
| 604 | + | return printPaste(got) | |||
| 605 | + | } | |||
| 606 | + | got = append(got, v...) | |||
| 607 | + | } | |||
| 608 | + | deadline = time.Now().Add(10 * time.Second) | |||
| 609 | + | } | |||
| 610 | + | } | |||
| 611 | + | ||||
| 612 | + | func printPaste(v []byte) error { | |||
| 613 | + | if len(v) <= 4096 { | |||
| 614 | + | fmt.Printf("paste %q\n", v) | |||
| 615 | + | } else { | |||
| 616 | + | fmt.Printf("paste len=%d sha256=%x\n", len(v), sha256.Sum256(v)) | |||
| 617 | + | } | |||
| 618 | + | return nil | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func (t *tool) xsettingsMode() error { | |||
| 622 | + | raw, err := t.rpc(xproto.GetSelectionOwner(t.b, t.atoms["_XSETTINGS_S0"])) | |||
| 623 | + | if err != nil { | |||
| 624 | + | return err | |||
| 625 | + | } | |||
| 626 | + | r, ok := xproto.ParseGetSelectionOwnerReply(raw) | |||
| 627 | + | if !ok || r.Owner() == 0 { | |||
| 628 | + | fmt.Println("xsettings none") | |||
| 629 | + | return nil | |||
| 630 | + | } | |||
| 631 | + | raw, err = t.rpc(xproto.GetProperty(t.b, false, r.Owner(), t.atoms["_XSETTINGS_SETTINGS"], xproto.AtomAny, 0, 1<<16)) | |||
| 632 | + | if err != nil { | |||
| 633 | + | return err | |||
| 634 | + | } | |||
| 635 | + | pr, ok := xproto.ParseGetPropertyReply(raw) | |||
| 636 | + | if !ok || len(pr.Value()) < 12 { | |||
| 637 | + | fmt.Println("xsettings none") | |||
| 638 | + | return nil | |||
| 639 | + | } | |||
| 640 | + | v := pr.Value() | |||
| 641 | + | if v[0] != 0 { | |||
| 642 | + | return fmt.Errorf("xsettings: big-endian data") | |||
| 643 | + | } | |||
| 644 | + | le := binary.LittleEndian | |||
| 645 | + | serial, n := le.Uint32(v[4:]), int(le.Uint32(v[8:])) | |||
| 646 | + | vals := map[string]string{} | |||
| 647 | + | p := v[12:] | |||
| 648 | + | pad := func(n int) int { return (n + 3) &^ 3 } | |||
| 649 | + | for i := 0; i < n; i++ { | |||
| 650 | + | if len(p) < 4 { | |||
| 651 | + | return fmt.Errorf("xsettings: truncated") | |||
| 652 | + | } | |||
| 653 | + | typ, nl := p[0], int(le.Uint16(p[2:])) | |||
| 654 | + | if len(p) < 4+pad(nl)+4 { | |||
| 655 | + | return fmt.Errorf("xsettings: truncated") | |||
| 656 | + | } | |||
| 657 | + | name := string(p[4 : 4+nl]) | |||
| 658 | + | p = p[4+pad(nl)+4:] // name, last-change-serial | |||
| 659 | + | switch typ { | |||
| 660 | + | case 0: | |||
| 661 | + | vals[name] = strconv.Itoa(int(int32(le.Uint32(p)))) | |||
| 662 | + | p = p[4:] | |||
| 663 | + | case 1: | |||
| 664 | + | sl := int(le.Uint32(p)) | |||
| 665 | + | vals[name] = string(p[4 : 4+sl]) | |||
| 666 | + | p = p[4+pad(sl):] | |||
| 667 | + | case 2: | |||
| 668 | + | p = p[8:] | |||
| 669 | + | default: | |||
| 670 | + | return fmt.Errorf("xsettings: type %d", typ) | |||
| 671 | + | } | |||
| 672 | + | } | |||
| 673 | + | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gdk/WindowScalingFactor=%s Gdk/UnscaledDPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", | |||
| 674 | + | serial, vals["Xft/DPI"], vals["Gdk/WindowScalingFactor"], vals["Gdk/UnscaledDPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) | |||
| 675 | + | return nil | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | func (t *tool) cursorMode() error { | |||
| 679 | + | if _, err := t.rpc(xfixes.QueryVersion(t.b, 4, 0)); err != nil { | |||
| 680 | + | return err | |||
| 681 | + | } | |||
| 682 | + | raw, err := t.rpc(xfixes.GetCursorImage(t.b)) | |||
| 683 | + | if err != nil { | |||
| 684 | + | return err | |||
| 685 | + | } | |||
| 686 | + | r, ok := xfixes.ParseGetCursorImageReply(raw) | |||
| 687 | + | if !ok { | |||
| 688 | + | return fmt.Errorf("bad GetCursorImage reply") | |||
| 689 | + | } | |||
| 690 | + | fmt.Printf("cursor %dx%d\n", r.Width(), r.Height()) | |||
| 691 | + | return nil | |||
| 692 | + | } | |||
A internal/app/xwayland_test.go +808 -0
| @@ -0,0 +1,808 @@ | |||||
| 1 | + | //go:build xwayland | |||
| 2 | + | ||||
| 3 | + | // End-to-end test of the built-in XWM: a real Xwayland and a real X11 client | |||
| 4 | + | // (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on | |||
| 5 | + | // PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with | |||
| 6 | + | // `make e2e-xwayland`. | |||
| 7 | + | package app | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "crypto/sha256" | |||
| 12 | + | "fmt" | |||
| 13 | + | "image" | |||
| 14 | + | "image/png" | |||
| 15 | + | "io" | |||
| 16 | + | "os" | |||
| 17 | + | "os/exec" | |||
| 18 | + | "path/filepath" | |||
| 19 | + | "regexp" | |||
| 20 | + | "strconv" | |||
| 21 | + | "strings" | |||
| 22 | + | "sync" | |||
| 23 | + | "testing" | |||
| 24 | + | "time" | |||
| 25 | + | ||||
| 26 | + | "github.com/bnema/zerowrap" | |||
| 27 | + | ||||
| 28 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 29 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |||
| 30 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | xwaylandTimeout = 10 * time.Second | |||
| 35 | + | xwaylandW, xwaylandH = 640, 360 | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`) | |||
| 39 | + | ||||
| 40 | + | // probe is a running x11probe. | |||
| 41 | + | type probe struct { | |||
| 42 | + | cmd *exec.Cmd | |||
| 43 | + | out *lockedBuffer | |||
| 44 | + | waited chan error | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // xwaylandEnv is a running headless compositor with the built-in XWM. | |||
| 48 | + | type xwaylandEnv struct { | |||
| 49 | + | bin, display, statePath, shots string | |||
| 50 | + | script *io.PipeWriter | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // startXwayland runs the compositor on one headless output and waits for its | |||
| 54 | + | // X11 display. It skips the test without Xwayland and stops everything when | |||
| 55 | + | // the test ends. | |||
| 56 | + | func startXwayland(t *testing.T) *xwaylandEnv { | |||
| 57 | + | t.Helper() | |||
| 58 | + | return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // startXwaylandWith is startXwayland with the given headless outputs, left to | |||
| 62 | + | // right, and an optional change to the default config. | |||
| 63 | + | func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv { | |||
| 64 | + | t.Helper() | |||
| 65 | + | if _, err := exec.LookPath("Xwayland"); err != nil { | |||
| 66 | + | t.Skip("Xwayland not on PATH") | |||
| 67 | + | } | |||
| 68 | + | // The compositor must not read or write the live session's state. | |||
| 69 | + | for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} { | |||
| 70 | + | if v, ok := os.LookupEnv(k); ok { | |||
| 71 | + | os.Unsetenv(k) | |||
| 72 | + | t.Cleanup(func() { os.Setenv(k, v) }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | runtime := t.TempDir() | |||
| 76 | + | t.Setenv("XDG_RUNTIME_DIR", runtime) | |||
| 77 | + | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 78 | + | shots := t.TempDir() | |||
| 79 | + | bin := buildProbe(t) | |||
| 80 | + | ||||
| 81 | + | script, scriptW := io.Pipe() | |||
| 82 | + | logs := &lockedBuffer{} | |||
| 83 | + | ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs}))) | |||
| 84 | + | cfg := config.Defaults() | |||
| 85 | + | cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true} | |||
| 86 | + | if mutate != nil { | |||
| 87 | + | mutate(&cfg) | |||
| 88 | + | } | |||
| 89 | + | done := make(chan error, 1) | |||
| 90 | + | go func() { | |||
| 91 | + | done <- Run(ctx, Options{ | |||
| 92 | + | Backend: "headless", NoTerminal: true, Config: cfg, Script: script, | |||
| 93 | + | ScreenshotDir: shots, Sizes: sizes, | |||
| 94 | + | }) | |||
| 95 | + | }() | |||
| 96 | + | t.Cleanup(func() { | |||
| 97 | + | if t.Failed() { | |||
| 98 | + | t.Logf("compositor log:\n%s", logs.String()) | |||
| 99 | + | } | |||
| 100 | + | cancel() | |||
| 101 | + | _ = scriptW.Close() | |||
| 102 | + | select { | |||
| 103 | + | case err := <-done: | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Errorf("Run: %v", err) | |||
| 106 | + | } | |||
| 107 | + | case <-time.After(5 * time.Second): | |||
| 108 | + | t.Error("Run did not stop") | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | ||||
| 112 | + | var display string | |||
| 113 | + | until(t, "X11 display in the log", func() bool { | |||
| 114 | + | if m := displayRe.FindStringSubmatch(logs.String()); m != nil { | |||
| 115 | + | display = m[1] | |||
| 116 | + | return true | |||
| 117 | + | } | |||
| 118 | + | return false | |||
| 119 | + | }) | |||
| 120 | + | statePath, err := statefile.Path(runtime, "wayland-1") | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW} | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows | |||
| 128 | + | // up in the state with its class and pid, is drawn, tiles next to a second | |||
| 129 | + | // one, receives focus and is closed by the close-window bind. | |||
| 130 | + | func TestXwaylandProbe(t *testing.T) { | |||
| 131 | + | e := startXwayland(t) | |||
| 132 | + | bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script | |||
| 133 | + | first := startProbe(t, bin, display, "-title", "one") | |||
| 134 | + | until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") }) | |||
| 135 | + | ||||
| 136 | + | var st statefile.State | |||
| 137 | + | until(t, "one visible X11Probe window", func() bool { | |||
| 138 | + | st = readState(statePath) | |||
| 139 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible | |||
| 140 | + | }) | |||
| 141 | + | if w := st.Windows[0]; w.PID != first.cmd.Process.Pid { | |||
| 142 | + | t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // The client draws green and the compositor shows it. | |||
| 146 | + | id := st.Windows[0].ID | |||
| 147 | + | until(t, "green window in latest.png", func() bool { | |||
| 148 | + | r, ok := tileRect(readState(statePath), id) | |||
| 149 | + | return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5 | |||
| 150 | + | }) | |||
| 151 | + | ||||
| 152 | + | // Focus round trip: the compositor focused the window, the client saw it. | |||
| 153 | + | until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") }) | |||
| 154 | + | ||||
| 155 | + | // A second probe tiles beside the first. | |||
| 156 | + | second := startProbe(t, bin, display, "-title", "two") | |||
| 157 | + | until(t, "two tiled windows", func() bool { | |||
| 158 | + | st = readState(statePath) | |||
| 159 | + | if len(st.Windows) != 2 { | |||
| 160 | + | return false | |||
| 161 | + | } | |||
| 162 | + | for _, w := range st.Windows { | |||
| 163 | + | if w.AppID != "X11Probe" || !w.Visible || w.Floating { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | return true | |||
| 168 | + | }) | |||
| 169 | + | until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") }) | |||
| 170 | + | ||||
| 171 | + | // The close-window bind asks the focused (second) probe to close. | |||
| 172 | + | if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | select { | |||
| 176 | + | case err := <-second.waited: | |||
| 177 | + | if err != nil { | |||
| 178 | + | t.Errorf("probe two exited with %v, want 0", err) | |||
| 179 | + | } | |||
| 180 | + | case <-time.After(2 * time.Second): | |||
| 181 | + | t.Fatal("probe two did not exit within 2s of Super+q") | |||
| 182 | + | } | |||
| 183 | + | until(t, "one window left", func() bool { | |||
| 184 | + | st = readState(statePath) | |||
| 185 | + | return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid | |||
| 186 | + | }) | |||
| 187 | + | select { | |||
| 188 | + | case err := <-first.waited: | |||
| 189 | + | t.Fatalf("probe one exited (%v) though only the focused probe was closed", err) | |||
| 190 | + | default: | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // buildProbe builds testdata/x11probe. | |||
| 195 | + | func buildProbe(t *testing.T) string { | |||
| 196 | + | t.Helper() | |||
| 197 | + | bin := filepath.Join(t.TempDir(), "x11probe") | |||
| 198 | + | build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe") | |||
| 199 | + | build.Env = append(os.Environ(), "CGO_ENABLED=0") | |||
| 200 | + | if out, err := build.CombinedOutput(); err != nil { | |||
| 201 | + | t.Fatalf("build x11probe: %v: %s", err, out) | |||
| 202 | + | } | |||
| 203 | + | return bin | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // startProbe starts x11probe on display, with X11 as its only display | |||
| 207 | + | // server, and stops it when the test ends. | |||
| 208 | + | func startProbe(t *testing.T, bin, display string, args ...string) *probe { | |||
| 209 | + | t.Helper() | |||
| 210 | + | p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)} | |||
| 211 | + | p.cmd = exec.Command(bin, args...) | |||
| 212 | + | p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display) | |||
| 213 | + | p.cmd.Stdout = p.out | |||
| 214 | + | stderr := &lockedBuffer{} | |||
| 215 | + | p.cmd.Stderr = stderr | |||
| 216 | + | if err := p.cmd.Start(); err != nil { | |||
| 217 | + | t.Fatal(err) | |||
| 218 | + | } | |||
| 219 | + | var exited sync.Once | |||
| 220 | + | go func() { p.waited <- p.cmd.Wait() }() | |||
| 221 | + | t.Cleanup(func() { | |||
| 222 | + | exited.Do(func() { | |||
| 223 | + | _ = p.cmd.Process.Kill() | |||
| 224 | + | select { | |||
| 225 | + | case <-p.waited: | |||
| 226 | + | case <-time.After(2 * time.Second): | |||
| 227 | + | } | |||
| 228 | + | }) | |||
| 229 | + | if s := stderr.String(); s != "" { | |||
| 230 | + | t.Logf("x11probe stderr: %s", s) | |||
| 231 | + | } | |||
| 232 | + | }) | |||
| 233 | + | return p | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func envWithout(keys ...string) []string { | |||
| 237 | + | var env []string | |||
| 238 | + | next: | |||
| 239 | + | for _, kv := range os.Environ() { | |||
| 240 | + | for _, k := range keys { | |||
| 241 | + | if strings.HasPrefix(kv, k+"=") { | |||
| 242 | + | continue next | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | env = append(env, kv) | |||
| 246 | + | } | |||
| 247 | + | return env | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // until polls cond every 20 ms and fails the test after xwaylandTimeout. | |||
| 251 | + | func until(t *testing.T, what string, cond func() bool) { | |||
| 252 | + | t.Helper() | |||
| 253 | + | for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) { | |||
| 254 | + | if time.Now().After(deadline) { | |||
| 255 | + | t.Fatalf("timeout waiting for %s", what) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // readState returns the current state file, or an empty state while it does | |||
| 261 | + | // not exist or is being replaced. | |||
| 262 | + | func readState(path string) statefile.State { | |||
| 263 | + | st, err := statefile.Read(path) | |||
| 264 | + | if err != nil { | |||
| 265 | + | return statefile.State{} | |||
| 266 | + | } | |||
| 267 | + | return st | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // tileRect finds the rectangle of window id in the tiled columns. | |||
| 271 | + | func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) { | |||
| 272 | + | for _, ws := range st.Workspaces { | |||
| 273 | + | for _, c := range ws.Columns { | |||
| 274 | + | for _, w := range c.Windows { | |||
| 275 | + | if w.ID == id { | |||
| 276 | + | return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | return image.Rectangle{}, false | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // greenShare is the fraction of pixels of r in the PNG at path that are | |||
| 285 | + | // clearly green; 0 when the image cannot be read yet. | |||
| 286 | + | func greenShare(path string, r image.Rectangle) float64 { | |||
| 287 | + | f, err := os.Open(path) | |||
| 288 | + | if err != nil { | |||
| 289 | + | return 0 | |||
| 290 | + | } | |||
| 291 | + | defer f.Close() | |||
| 292 | + | img, err := png.Decode(f) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return 0 | |||
| 295 | + | } | |||
| 296 | + | r = r.Intersect(img.Bounds()) | |||
| 297 | + | if r.Empty() { | |||
| 298 | + | return 0 | |||
| 299 | + | } | |||
| 300 | + | n := 0 | |||
| 301 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 302 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 303 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 304 | + | if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 { | |||
| 305 | + | n++ | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // magentaShare is the fraction of pixels of r in the PNG at path that are | |||
| 313 | + | // clearly magenta; 0 when the image cannot be read yet. | |||
| 314 | + | func magentaShare(path string, r image.Rectangle) float64 { | |||
| 315 | + | f, err := os.Open(path) | |||
| 316 | + | if err != nil { | |||
| 317 | + | return 0 | |||
| 318 | + | } | |||
| 319 | + | defer f.Close() | |||
| 320 | + | img, err := png.Decode(f) | |||
| 321 | + | if err != nil { | |||
| 322 | + | return 0 | |||
| 323 | + | } | |||
| 324 | + | r = r.Intersect(img.Bounds()) | |||
| 325 | + | if r.Empty() { | |||
| 326 | + | return 0 | |||
| 327 | + | } | |||
| 328 | + | n := 0 | |||
| 329 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 330 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 331 | + | cr, cg, cb, _ := img.At(x, y).RGBA() | |||
| 332 | + | if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 { | |||
| 333 | + | n++ | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | return float64(n) / float64(r.Dx()*r.Dy()) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // TestXwaylandOverride: an override-redirect window (a menu) of an X11 client | |||
| 341 | + | // is drawn above the toplevel at its X position, is not a window in the | |||
| 342 | + | // state, leaves keyboard focus on the toplevel and vanishes when unmapped. | |||
| 343 | + | func TestXwaylandOverride(t *testing.T) { | |||
| 344 | + | e := startXwayland(t) | |||
| 345 | + | // Probe unmaps the menu 3 s after mapping it; the first checks run well | |||
| 346 | + | // inside that window. | |||
| 347 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50", | |||
| 348 | + | "-override-size", "120x80", "-override-for", "3s") | |||
| 349 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 350 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 351 | + | box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin | |||
| 352 | + | until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 }) | |||
| 353 | + | ||||
| 354 | + | var st statefile.State | |||
| 355 | + | until(t, "focused X11Probe toplevel", func() bool { | |||
| 356 | + | st = readState(e.statePath) | |||
| 357 | + | return len(st.Windows) >= 1 && st.Window != nil | |||
| 358 | + | }) | |||
| 359 | + | if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" { | |||
| 360 | + | t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows) | |||
| 361 | + | } | |||
| 362 | + | id := st.Windows[0].ID | |||
| 363 | + | if st.Window.ID != id { | |||
| 364 | + | t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id) | |||
| 365 | + | } | |||
| 366 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 367 | + | ||||
| 368 | + | // The menu is above the toplevel: its box is magenta, not green. | |||
| 369 | + | if g := greenShare(shot, box); g > 0.1 { | |||
| 370 | + | t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g) | |||
| 371 | + | } | |||
| 372 | + | until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") }) | |||
| 373 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 374 | + | return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9 | |||
| 375 | + | }) | |||
| 376 | + | if st := readState(e.statePath); st.Window == nil || st.Window.ID != id { | |||
| 377 | + | t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel | |||
| 382 | + | // when core focuses another window, which is how toolkits close their menus. | |||
| 383 | + | func TestXwaylandOverrideDismiss(t *testing.T) { | |||
| 384 | + | e := startXwayland(t) | |||
| 385 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout") | |||
| 386 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 387 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 388 | + | // A second X11 window takes the focus away from the menu owner. | |||
| 389 | + | startProbe(t, e.bin, e.display, "-title", "other") | |||
| 390 | + | until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") }) | |||
| 391 | + | box := image.Rect(100, 50, 220, 130) | |||
| 392 | + | until(t, "menu gone from latest.png", func() bool { | |||
| 393 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0 | |||
| 394 | + | }) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | // TestXwaylandOverrideShape: a menu with an empty input shape is still drawn | |||
| 398 | + | // and still not a window. | |||
| 399 | + | func TestXwaylandOverrideShape(t *testing.T) { | |||
| 400 | + | e := startXwayland(t) | |||
| 401 | + | p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip") | |||
| 402 | + | until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") }) | |||
| 403 | + | until(t, "magenta tooltip in latest.png", func() bool { | |||
| 404 | + | return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9 | |||
| 405 | + | }) | |||
| 406 | + | until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 }) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // fullOutput is the share of the output screenshot at path that is green. | |||
| 410 | + | func fullOutput(path string) float64 { | |||
| 411 | + | return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH)) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // windowOf returns the state window with the given pid. | |||
| 415 | + | func windowOf(st statefile.State, pid int) (statefile.Window, bool) { | |||
| 416 | + | for _, w := range st.Windows { | |||
| 417 | + | if w.PID == pid { | |||
| 418 | + | return w, true | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | return statefile.Window{}, false | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | // TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the | |||
| 425 | + | // output, the toggle bind leaves it, and a request made right at the map is | |||
| 426 | + | // ignored (the grace, current policy). | |||
| 427 | + | func TestXwaylandFullscreen(t *testing.T) { | |||
| 428 | + | // Gaps make a tile visibly smaller than the output. | |||
| 429 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |||
| 430 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 431 | + | corner := image.Rect(0, 0, 4, 4) | |||
| 432 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s") | |||
| 433 | + | until(t, "tiled with a gap", func() bool { | |||
| 434 | + | return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0 | |||
| 435 | + | }) | |||
| 436 | + | until(t, "probe fullscreen at output size", func() bool { | |||
| 437 | + | return strings.Contains(p.out.String(), "fullscreen 640x360") | |||
| 438 | + | }) | |||
| 439 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |||
| 440 | + | ||||
| 441 | + | // The toggle bind (cmd+shift+f) leaves fullscreen. | |||
| 442 | + | if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil { | |||
| 443 | + | t.Fatal(err) | |||
| 444 | + | } | |||
| 445 | + | until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 446 | + | until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 }) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // TestXwaylandFullscreenOff: the client leaves fullscreen itself. | |||
| 450 | + | func TestXwaylandFullscreenOff(t *testing.T) { | |||
| 451 | + | e := startXwayland(t) | |||
| 452 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s") | |||
| 453 | + | until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 454 | + | until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 455 | + | until(t, "tiled", func() bool { | |||
| 456 | + | st := readState(e.statePath) | |||
| 457 | + | return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }() | |||
| 458 | + | }) | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | // TestXwaylandInitialFullscreen: a fixed-size window that maps with | |||
| 462 | + | // _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a | |||
| 463 | + | // column of its own, focused, beside the tiled window already open. This is | |||
| 464 | + | // the state it maps with, not a request: the grace does not apply. | |||
| 465 | + | func TestXwaylandInitialFullscreen(t *testing.T) { | |||
| 466 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 }) | |||
| 467 | + | shot := filepath.Join(e.shots, "latest.png") | |||
| 468 | + | first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100") | |||
| 469 | + | until(t, "first tiled", func() bool { | |||
| 470 | + | return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1 | |||
| 471 | + | }) | |||
| 472 | + | p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360") | |||
| 473 | + | until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 474 | + | until(t, "game in its own column, fullscreen", func() bool { | |||
| 475 | + | st := readState(e.statePath) | |||
| 476 | + | for _, w := range st.Windows { | |||
| 477 | + | if w.AppID == "Game" { | |||
| 478 | + | return !w.Floating && w.Column == 2 && w.Row == 1 | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | return false | |||
| 482 | + | }) | |||
| 483 | + | until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 }) | |||
| 484 | + | until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | // TestXwaylandFullscreenGrace: a request made at once after the map (not the | |||
| 488 | + | // state it maps with) stays tiled (fullscreenGrace in core). | |||
| 489 | + | func TestXwaylandFullscreenGrace(t *testing.T) { | |||
| 490 | + | e := startXwayland(t) | |||
| 491 | + | p := startProbe(t, e.bin, e.display, "-fullscreen") | |||
| 492 | + | until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 493 | + | until(t, "tiled window", func() bool { | |||
| 494 | + | st := readState(e.statePath) | |||
| 495 | + | return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }() | |||
| 496 | + | }) | |||
| 497 | + | // Stay inside the grace, then check it is still not fullscreen. | |||
| 498 | + | time.Sleep(500 * time.Millisecond) | |||
| 499 | + | st := readState(e.statePath) | |||
| 500 | + | if _, ok := tileRect(st, st.Windows[0].ID); !ok { | |||
| 501 | + | t.Error("window went fullscreen inside the grace") | |||
| 502 | + | } | |||
| 503 | + | if strings.Contains(p.out.String(), "fullscreen ") { | |||
| 504 | + | t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String()) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | // TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the | |||
| 509 | + | // keyboard grab the close bind does not fire; after it leaves fullscreen the | |||
| 510 | + | // binds work again. | |||
| 511 | + | func TestXwaylandKeyboardGrab(t *testing.T) { | |||
| 512 | + | e := startXwayland(t) | |||
| 513 | + | p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab") | |||
| 514 | + | until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 515 | + | until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") }) | |||
| 516 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 517 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 518 | + | t.Fatal(err) | |||
| 519 | + | } | |||
| 520 | + | select { | |||
| 521 | + | case err := <-p.waited: | |||
| 522 | + | t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err) | |||
| 523 | + | case <-time.After(2 * time.Second): | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | // The probe leaves fullscreen itself; its grab stays, but the binds work again. | |||
| 527 | + | until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") }) | |||
| 528 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 529 | + | t.Fatal(err) | |||
| 530 | + | } | |||
| 531 | + | select { | |||
| 532 | + | case err := <-p.waited: | |||
| 533 | + | if err != nil { | |||
| 534 | + | t.Errorf("probe exited with %v, want 0", err) | |||
| 535 | + | } | |||
| 536 | + | case <-time.After(2 * time.Second): | |||
| 537 | + | t.Fatal("Super+q did not close the client after it left fullscreen") | |||
| 538 | + | } | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | // TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock | |||
| 542 | + | // the binds. | |||
| 543 | + | func TestXwaylandKeyboardGrabWindowed(t *testing.T) { | |||
| 544 | + | e := startXwayland(t) | |||
| 545 | + | p := startProbe(t, e.bin, e.display, "-keyboard-grab") | |||
| 546 | + | until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") }) | |||
| 547 | + | until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") }) | |||
| 548 | + | if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil { | |||
| 549 | + | t.Fatal(err) | |||
| 550 | + | } | |||
| 551 | + | select { | |||
| 552 | + | case err := <-p.waited: | |||
| 553 | + | if err != nil { | |||
| 554 | + | t.Errorf("probe exited with %v, want 0", err) | |||
| 555 | + | } | |||
| 556 | + | case <-time.After(2 * time.Second): | |||
| 557 | + | t.Fatal("Super+q did not close a windowed client that holds a keyboard grab") | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | // TestXwaylandOutputHint: with two outputs a window created at the origin of | |||
| 562 | + | // the second one opens on it, and a fullscreen request fills that output. | |||
| 563 | + | func TestXwaylandOutputHint(t *testing.T) { | |||
| 564 | + | sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}} | |||
| 565 | + | e := startXwaylandWith(t, sizes, nil) | |||
| 566 | + | p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms") | |||
| 567 | + | var w statefile.Window | |||
| 568 | + | until(t, "window on HEADLESS-2", func() bool { | |||
| 569 | + | var ok bool | |||
| 570 | + | w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid) | |||
| 571 | + | if ok && w.Output != "HEADLESS-2" { | |||
| 572 | + | t.Logf("window output = %s", w.Output) | |||
| 573 | + | } | |||
| 574 | + | return ok && w.Output == "HEADLESS-2" | |||
| 575 | + | }) | |||
| 576 | + | until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") }) | |||
| 577 | + | until(t, "HEADLESS-2 all green", func() bool { | |||
| 578 | + | return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99 | |||
| 579 | + | }) | |||
| 580 | + | if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 { | |||
| 581 | + | t.Errorf("HEADLESS-1 green share = %.2f, want empty", g) | |||
| 582 | + | } | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window | |||
| 586 | + | // the way it matches a Wayland app ID (steam_app_<id> from Proton). | |||
| 587 | + | func TestXwaylandClassRule(t *testing.T) { | |||
| 588 | + | yes := true | |||
| 589 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | |||
| 590 | + | c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes}) | |||
| 591 | + | }) | |||
| 592 | + | startProbe(t, e.bin, e.display, "-class", "steam_app_413150") | |||
| 593 | + | until(t, "floating steam_app window", func() bool { | |||
| 594 | + | st := readState(e.statePath) | |||
| 595 | + | return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating | |||
| 596 | + | }) | |||
| 597 | + | startProbe(t, e.bin, e.display, "-class", "Other") | |||
| 598 | + | until(t, "other class tiled", func() bool { | |||
| 599 | + | for _, w := range readState(e.statePath).Windows { | |||
| 600 | + | if w.AppID == "Other" { | |||
| 601 | + | return !w.Floating | |||
| 602 | + | } | |||
| 603 | + | } | |||
| 604 | + | return false | |||
| 605 | + | }) | |||
| 606 | + | } | |||
| 607 | + | ||||
| 608 | + | // waylandEnv is the environment of a Wayland client of the headless | |||
| 609 | + | // compositor: its socket, not the user's session. | |||
| 610 | + | func waylandEnv() []string { | |||
| 611 | + | return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1") | |||
| 612 | + | } | |||
| 613 | + | ||||
| 614 | + | // runProbe runs a one-shot x11probe mode and returns its stdout. | |||
| 615 | + | func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string { | |||
| 616 | + | t.Helper() | |||
| 617 | + | ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout) | |||
| 618 | + | defer cancel() | |||
| 619 | + | cmd := exec.CommandContext(ctx, e.bin, args...) | |||
| 620 | + | cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display) | |||
| 621 | + | out, err := cmd.Output() | |||
| 622 | + | if err != nil { | |||
| 623 | + | t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out) | |||
| 624 | + | } | |||
| 625 | + | return strings.TrimSpace(string(out)) | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | // needWlClipboard skips the test without wl-copy and wl-paste. | |||
| 629 | + | func needWlClipboard(t *testing.T) { | |||
| 630 | + | t.Helper() | |||
| 631 | + | for _, c := range []string{"wl-copy", "wl-paste"} { | |||
| 632 | + | if _, err := exec.LookPath(c); err != nil { | |||
| 633 | + | t.Skipf("%s not on PATH (wl-clipboard)", c) | |||
| 634 | + | } | |||
| 635 | + | } | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | // wlCopy starts wl-copy --foreground serving data on the headless | |||
| 639 | + | // compositor's seat and stops it when the test ends. | |||
| 640 | + | func wlCopy(t *testing.T, data string, args ...string) { | |||
| 641 | + | t.Helper() | |||
| 642 | + | cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...) | |||
| 643 | + | cmd.Env = waylandEnv() | |||
| 644 | + | cmd.Stdin = strings.NewReader(data) | |||
| 645 | + | stderr := &lockedBuffer{} | |||
| 646 | + | cmd.Stderr = stderr | |||
| 647 | + | if err := cmd.Start(); err != nil { | |||
| 648 | + | t.Fatal(err) | |||
| 649 | + | } | |||
| 650 | + | waited := make(chan struct{}) | |||
| 651 | + | go func() { _ = cmd.Wait(); close(waited) }() | |||
| 652 | + | t.Cleanup(func() { | |||
| 653 | + | _ = cmd.Process.Kill() | |||
| 654 | + | <-waited | |||
| 655 | + | if s := stderr.String(); s != "" { | |||
| 656 | + | t.Logf("wl-copy stderr: %s", s) | |||
| 657 | + | } | |||
| 658 | + | }) | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | // wlPaste returns what wl-paste prints, and whether it succeeded. | |||
| 662 | + | func wlPaste(args ...string) (string, bool) { | |||
| 663 | + | ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout) | |||
| 664 | + | defer cancel() | |||
| 665 | + | cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...) | |||
| 666 | + | cmd.Env = waylandEnv() | |||
| 667 | + | out, err := cmd.Output() | |||
| 668 | + | return string(out), err == nil | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // payload is n bytes of printable text that differ along the way, so a | |||
| 672 | + | // misplaced chunk is caught. | |||
| 673 | + | func payload(n int) string { | |||
| 674 | + | var b strings.Builder | |||
| 675 | + | for i := 0; b.Len() < n; i++ { | |||
| 676 | + | fmt.Fprintf(&b, "line %d é\n", i) | |||
| 677 | + | } | |||
| 678 | + | return b.String()[:n] | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // pasteLine is what x11probe -paste prints for want. | |||
| 682 | + | func pasteLine(want string) string { | |||
| 683 | + | if len(want) <= 4096 { | |||
| 684 | + | return fmt.Sprintf("paste %q", want) | |||
| 685 | + | } | |||
| 686 | + | return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want))) | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | // TestXwaylandClipboard: the clipboard and the primary selection cross the | |||
| 690 | + | // X11/Wayland boundary in both directions, text only, small and INCR sized. | |||
| 691 | + | func TestXwaylandClipboard(t *testing.T) { | |||
| 692 | + | needWlClipboard(t) | |||
| 693 | + | e := startXwayland(t) | |||
| 694 | + | startProbe(t, e.bin, e.display, "-title", "focus") | |||
| 695 | + | until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil }) | |||
| 696 | + | ||||
| 697 | + | for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} { | |||
| 698 | + | for _, size := range []int{4 << 10, 1 << 20} { | |||
| 699 | + | name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10) | |||
| 700 | + | text := payload(size) | |||
| 701 | + | t.Run("W2X/"+name, func(t *testing.T) { | |||
| 702 | + | var wargs []string | |||
| 703 | + | if sel.w != "" { | |||
| 704 | + | wargs = append(wargs, sel.w) | |||
| 705 | + | } | |||
| 706 | + | wlCopy(t, text, wargs...) | |||
| 707 | + | until(t, "x11probe pastes the Wayland copy", func() bool { | |||
| 708 | + | return runProbe(t, e, "-paste", sel.x) == pasteLine(text) | |||
| 709 | + | }) | |||
| 710 | + | }) | |||
| 711 | + | t.Run("X2W/"+name, func(t *testing.T) { | |||
| 712 | + | file := filepath.Join(t.TempDir(), "text") | |||
| 713 | + | if err := os.WriteFile(file, []byte(text), 0o600); err != nil { | |||
| 714 | + | t.Fatal(err) | |||
| 715 | + | } | |||
| 716 | + | p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s") | |||
| 717 | + | until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") }) | |||
| 718 | + | var wargs []string | |||
| 719 | + | if sel.w != "" { | |||
| 720 | + | wargs = append(wargs, sel.w) | |||
| 721 | + | } | |||
| 722 | + | var got string | |||
| 723 | + | until(t, "wl-paste returns the X copy", func() bool { | |||
| 724 | + | var ok bool | |||
| 725 | + | got, ok = wlPaste(wargs...) | |||
| 726 | + | return ok && got == text | |||
| 727 | + | }) | |||
| 728 | + | if got != text { | |||
| 729 | + | t.Errorf("wl-paste differs: %d bytes", len(got)) | |||
| 730 | + | } | |||
| 731 | + | }) | |||
| 732 | + | } | |||
| 733 | + | } | |||
| 734 | + | ||||
| 735 | + | t.Run("clear", func(t *testing.T) { | |||
| 736 | + | wlCopy(t, "to be cleared") | |||
| 737 | + | until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` }) | |||
| 738 | + | cmd := exec.Command("wl-copy", "--clear") | |||
| 739 | + | cmd.Env = waylandEnv() | |||
| 740 | + | if out, err := cmd.CombinedOutput(); err != nil { | |||
| 741 | + | t.Fatalf("wl-copy --clear: %v: %s", err, out) | |||
| 742 | + | } | |||
| 743 | + | until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" }) | |||
| 744 | + | }) | |||
| 745 | + | } | |||
| 746 | + | ||||
| 747 | + | // cursorTheme installs a one-arrow Xcursor theme of the given size and | |||
| 748 | + | // selects it through the XCURSOR_* environment. | |||
| 749 | + | func cursorTheme(t *testing.T, size int) { | |||
| 750 | + | t.Helper() | |||
| 751 | + | dir := t.TempDir() | |||
| 752 | + | themeCursor(t, dir, "left_ptr", size, 1) | |||
| 753 | + | t.Setenv("XCURSOR_PATH", dir) | |||
| 754 | + | t.Setenv("XCURSOR_THEME", "theme") | |||
| 755 | + | t.Setenv("XCURSOR_SIZE", strconv.Itoa(size)) | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | // TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme, | |||
| 759 | + | // the cursor size and a fixed scale 1 / 96 dpi. | |||
| 760 | + | func TestXwaylandXSettings(t *testing.T) { | |||
| 761 | + | cursorTheme(t, 32) | |||
| 762 | + | e := startXwayland(t) | |||
| 763 | + | want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" | |||
| 764 | + | var got string | |||
| 765 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |||
| 766 | + | if got != want { | |||
| 767 | + | t.Errorf("-xsettings = %q, want %q", got, want) | |||
| 768 | + | } | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | // TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the | |||
| 772 | + | // DPI stays 96 even on a scale-2 output. | |||
| 773 | + | func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) { | |||
| 774 | + | cursorTheme(t, 24) | |||
| 775 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | |||
| 776 | + | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true}) | |||
| 777 | + | }) | |||
| 778 | + | var got string | |||
| 779 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |||
| 780 | + | if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") { | |||
| 781 | + | t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got) | |||
| 782 | + | } | |||
| 783 | + | if !strings.Contains(got, "Gtk/CursorThemeSize=24") { | |||
| 784 | + | t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got) | |||
| 785 | + | } | |||
| 786 | + | } | |||
| 787 | + | ||||
| 788 | + | // TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager. | |||
| 789 | + | func TestXwaylandXSettingsOff(t *testing.T) { | |||
| 790 | + | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false }) | |||
| 791 | + | // A mapped window proves the XWM is up before the absence is checked. | |||
| 792 | + | p := startProbe(t, e.bin, e.display) | |||
| 793 | + | until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") }) | |||
| 794 | + | if got := runProbe(t, e, "-xsettings"); got != "xsettings none" { | |||
| 795 | + | t.Errorf("-xsettings = %q, want none", got) | |||
| 796 | + | } | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | // TestXwaylandRootCursor: the X background shows the themed arrow, at the | |||
| 800 | + | // theme size. | |||
| 801 | + | func TestXwaylandRootCursor(t *testing.T) { | |||
| 802 | + | cursorTheme(t, 32) | |||
| 803 | + | e := startXwayland(t) | |||
| 804 | + | until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" }) | |||
| 805 | + | if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" { | |||
| 806 | + | t.Errorf("-root-cursor = %q, want cursor 32x32", got) | |||
| 807 | + | } | |||
| 808 | + | } | |||
M internal/core/client.go +8 -0
| @@ -70,6 +70,14 @@ func (c *Core) clientEvent(ctx context.Context, ev ports.ClientEvent) (publish, | |||||
| 70 | if _, w := c.screenOf(v.ID); w != nil { | 70 | if _, w := c.screenOf(v.ID); w != nil { | ||
| 71 | w.SetModal(v.ID, v.Modal) | 71 | w.SetModal(v.ID, v.Modal) | ||
| 72 | } | 72 | } | ||
| 73 | + | case ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped: | |||
| 74 | + | changed, err := c.unmanagedEvent(ctx, v) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return false, true | |||
| 77 | + | } | |||
| 78 | + | if !changed { | |||
| 79 | + | return false, false | |||
| 80 | + | } | |||
| 73 | case ports.WindowUnmapped: | 81 | case ports.WindowUnmapped: | ||
| 74 | if c.unmapClient(ctx, v) != nil { | 82 | if c.unmapClient(ctx, v) != nil { | ||
| 75 | return false, true | 83 | return false, true | ||
M internal/core/core.go +16 -5
| @@ -103,9 +103,11 @@ type Core struct { | |||||
| 103 | binds map[binding]Action | 103 | binds map[binding]Action | ||
| 104 | pressed map[string]bool | 104 | pressed map[string]bool | ||
| 105 | configures configures | 105 | configures configures | ||
| 106 | - | pointer WindowID | 106 | + | // placed is the last position (X, Y of the Rect) sent in a PlaceWindow, per window. |
| 107 | - | grab WindowID | 107 | + | placed map[WindowID]Rect |
| 108 | - | buttons map[uint32]bool | 108 | + | pointer WindowID |
| 109 | + | grab WindowID | |||
| 110 | + | buttons map[uint32]bool | |||
| 109 | // drag is the pointer drag in progress (drag.go); swallow holds the | 111 | // drag is the pointer drag in progress (drag.go); swallow holds the | ||
| 110 | // buttons whose release no client must see. mods are the modifiers | 112 | // buttons whose release no client must see. mods are the modifiers | ||
| 111 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | 113 | // held, cmdMod the one keyboard.cmd names. lastButton is the latest | ||
| @@ -144,7 +146,11 @@ type Core struct { | |||||
| 144 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | 146 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | ||
| 145 | popups map[WindowID]*popupState | 147 | popups map[WindowID]*popupState | ||
| 146 | popupOrder []WindowID | 148 | popupOrder []WindowID | ||
| 147 | - | keyboard keyboard | 149 | + | // unmanaged are the mapped override-redirect windows, unmanagedOrder |
| 150 | + | // stacks them, bottom to top (unmanaged.go). | |||
| 151 | + | unmanaged map[WindowID]*unmanagedState | |||
| 152 | + | unmanagedOrder []WindowID | |||
| 153 | + | keyboard keyboard | |||
| 148 | // activity is when core last told wayland about user input | 154 | // activity is when core last told wayland about user input | ||
| 149 | // (ports.UserActivity), sent at most once per ActivityInterval. | 155 | // (ports.UserActivity), sent at most once per ActivityInterval. | ||
| 150 | activity time.Time | 156 | activity time.Time | ||
| @@ -417,7 +423,7 @@ func New(cfg ports.Config, ch Channels, opts Options) (*Core, error) { | |||||
| 417 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | 423 | return nil, fmt.Errorf("scenes, layouts, constraints, state and workspaces must have capacity 1") | ||
| 418 | } | 424 | } | ||
| 419 | // A placeholder screen holds windows until the first output arrives. | 425 | // A placeholder screen holds windows until the first output arrives. | ||
| 420 | - | c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), ping: make(chan chan struct{}), out: newOutbox()} | 426 | + | c := &Core{inputKeys: map[string]bool{}, slots: map[slotKey]*slotState{}, placement: newSpawnPlacement(), windows: newWindowRegistry(), popups: map[WindowID]*popupState{}, unmanaged: map[WindowID]*unmanagedState{}, unmanagedOrder: make([]WindowID, 0, 32), ch: ch, opts: opts, pressed: map[string]bool{}, buttons: map[uint32]bool{}, swallow: map[uint32]bool{}, configures: newConfigures(), placed: map[WindowID]Rect{}, ping: make(chan chan struct{}), out: newOutbox()} |
| 421 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | 427 | c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}} | ||
| 422 | if err := c.apply(cfg); err != nil { | 428 | if err := c.apply(cfg); err != nil { | ||
| 423 | return nil, err | 429 | return nil, err | ||
| @@ -994,6 +1000,11 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 994 | } | 1000 | } | ||
| 995 | sc := c.screens[i] | 1001 | sc := c.screens[i] | ||
| 996 | lx, ly := x-float64(sc.x), y-float64(sc.y) | 1002 | lx, ly := x-float64(sc.x), y-float64(sc.y) | ||
| 1003 | + | // Unmanaged windows are over everything, popups included; a locked | |||
| 1004 | + | // pointer keeps its focus (a mouse-look game). | |||
| 1005 | + | if id, px, py := c.unmanagedAt(sc, lx, ly); id != 0 { | |||
| 1006 | + | return id, px, py | |||
| 1007 | + | } | |||
| 997 | // Layer popups are over everything; window popups are over the windows | 1008 | // Layer popups are over everything; window popups are over the windows | ||
| 998 | // only, under the top and overlay layers (as drawn). | 1009 | // only, under the top and overlay layers (as drawn). | ||
| 999 | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | 1010 | if id, px, py := c.popupAt(sc, lx, ly, true); id != 0 { | ||
M internal/core/core_test.go +50 -0
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "reflect" | 6 | "reflect" | ||
| 7 | + | "regexp" | |||
| 7 | "slices" | 8 | "slices" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "testing/synctest" | 10 | "testing/synctest" | ||
| @@ -38,6 +39,10 @@ func command(t *testing.T, ch <-chan ports.ClientCommand) ports.ClientCommand { | |||||
| 38 | for { | 39 | for { | ||
| 39 | switch v := receive(t, ch); v.(type) { | 40 | switch v := receive(t, ch); v.(type) { | ||
| 40 | case nil, ports.SetOutputs, ports.UserActivity: | 41 | case nil, ports.SetOutputs, ports.UserActivity: | ||
| 42 | + | case ports.PlaceWindow: | |||
| 43 | + | // Only Positioned (X11) windows are placed; every scenario | |||
| 44 | + | // using this helper maps xdg windows. | |||
| 45 | + | t.Fatalf("unexpected %+v", v) | |||
| 41 | default: | 46 | default: | ||
| 42 | return v | 47 | return v | ||
| 43 | } | 48 | } | ||
| @@ -1250,3 +1255,48 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||||
| 1250 | } | 1255 | } | ||
| 1251 | 1256 | ||||
| 1252 | } | 1257 | } | ||
| 1258 | + | ||||
| 1259 | + | // A window that maps with the fullscreen state (an X11 game with | |||
| 1260 | + | // _NET_WM_STATE_FULLSCREEN set before the map) covers its screen, focused, | |||
| 1261 | + | // in a tile of its own even when it was classified floating; a rule that | |||
| 1262 | + | // floats it does not change that. The grace still drops a later request. | |||
| 1263 | + | func TestMappedFullscreenHonoured(t *testing.T) { | |||
| 1264 | + | for _, tc := range []struct { | |||
| 1265 | + | name string | |||
| 1266 | + | rule bool | |||
| 1267 | + | }{{"fixed size", false}, {"floating rule", true}} { | |||
| 1268 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1269 | + | cfg := scrollDefaults() | |||
| 1270 | + | cfg.Border.Width = 0 | |||
| 1271 | + | if tc.rule { | |||
| 1272 | + | on := true | |||
| 1273 | + | cfg.Rules = []ports.WindowRule{{AppID: regexp.MustCompile("^game$"), Floating: &on}} | |||
| 1274 | + | } | |||
| 1275 | + | client := make(chan ports.ClientEvent, 8) | |||
| 1276 | + | output := make(chan ports.OutputEvent, 8) | |||
| 1277 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 1278 | + | scenes := make(chan []ports.Scene, 1) | |||
| 1279 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: make(chan ports.InputEvent, 8), Output: output, Commands: commands, Scenes: scenes}, core.Options{Clock: steppingClock(t)}) | |||
| 1280 | + | if err != nil { | |||
| 1281 | + | t.Fatal(err) | |||
| 1282 | + | } | |||
| 1283 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 1284 | + | defer cancel() | |||
| 1285 | + | go c.Run(ctx) | |||
| 1286 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 1287 | + | scene(t, scenes) | |||
| 1288 | + | client <- ports.WindowMapped{ID: 1} | |||
| 1289 | + | scene(t, scenes) | |||
| 1290 | + | client <- ports.WindowMapped{ID: 2, AppID: "game", Floating: true, Width: 100, Height: 80, Fullscreen: true} | |||
| 1291 | + | s := scene(t, scenes) | |||
| 1292 | + | for !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 && w.Fullscreen && w.Focused && !w.Hidden }) { | |||
| 1293 | + | s = scene(t, scenes) | |||
| 1294 | + | } | |||
| 1295 | + | for _, w := range s.Windows { | |||
| 1296 | + | if w.ID == 2 && (w.Rect.W != 100 || w.Rect.H != 80) { | |||
| 1297 | + | t.Fatalf("fullscreen rect %+v, want the output", w.Rect) | |||
| 1298 | + | } | |||
| 1299 | + | } | |||
| 1300 | + | }) | |||
| 1301 | + | } | |||
| 1302 | + | } | |||
M internal/core/datadrag.go +1 -1
| @@ -101,7 +101,7 @@ func dataDragHitChanged(ev ports.ClientEvent) bool { | |||||
| 101 | case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped, | 101 | case ports.LayerChanged, ports.InputRegionChanged, ports.WindowMapped, | ||
| 102 | ports.WindowResized, ports.PopupRequest, ports.PopupMapped, | 102 | ports.WindowResized, ports.PopupRequest, ports.PopupMapped, | ||
| 103 | ports.OutputPower, ports.WindowParent, ports.WindowModal, | 103 | ports.OutputPower, ports.WindowParent, ports.WindowModal, | ||
| 104 | - | ports.WindowUnmapped, ports.PointerWarp, ports.WindowMoveRequest, | 104 | + | ports.WindowUnmapped, ports.UnmanagedMapped, ports.UnmanagedMoved, ports.UnmanagedUnmapped, ports.PointerWarp, ports.WindowMoveRequest, |
| 105 | ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate: | 105 | ports.WindowFullscreenRequest, ports.WorkspaceActivate, ports.WindowActivate: | ||
| 106 | return true | 106 | return true | ||
| 107 | default: | 107 | default: | ||
A internal/core/place_test.go +80 -0
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/core" | |||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // places collects the PlaceWindow commands queued so far. | |||
| 12 | + | func places(commands chan ports.ClientCommand) (out []ports.PlaceWindow) { | |||
| 13 | + | for len(commands) > 0 { | |||
| 14 | + | if v, ok := (<-commands).(ports.PlaceWindow); ok { | |||
| 15 | + | out = append(out, v) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | return out | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestPlaceWindow(t *testing.T) { | |||
| 22 | + | cfg := altCmdDefaults() | |||
| 23 | + | cfg.Border.Width = 0 | |||
| 24 | + | cfg.Layout.Gaps = 8 | |||
| 25 | + | client := make(chan ports.ClientEvent, 16) | |||
| 26 | + | input := make(chan ports.InputEvent, 16) | |||
| 27 | + | output := make(chan ports.OutputEvent, 8) | |||
| 28 | + | commands := make(chan ports.ClientCommand, 256) | |||
| 29 | + | scenes := make(chan []ports.Scene, 1) | |||
| 30 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}, core.Options{}) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 35 | + | defer cancel() | |||
| 36 | + | go func() { _ = c.Run(ctx) }() | |||
| 37 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 38 | + | scene(t, scenes) | |||
| 39 | + | ||||
| 40 | + | // An xdg window is never placed. | |||
| 41 | + | client <- ports.WindowMapped{ID: 1} | |||
| 42 | + | scene(t, scenes) | |||
| 43 | + | if got := places(commands); len(got) != 0 { | |||
| 44 | + | t.Fatal(got) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // A positioned window is placed once on map. | |||
| 48 | + | client <- ports.WindowMapped{ID: 2, Positioned: true} | |||
| 49 | + | scene(t, scenes) | |||
| 50 | + | first := places(commands) | |||
| 51 | + | if len(first) != 1 || first[0].ID != 2 { | |||
| 52 | + | t.Fatal(first) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // The same layout sends nothing more. | |||
| 56 | + | client <- ports.WindowMapped{ID: 1} | |||
| 57 | + | scene(t, scenes) | |||
| 58 | + | if got := places(commands); len(got) != 0 { | |||
| 59 | + | t.Fatal(got) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // A position change sends it again, for the positioned window only. | |||
| 63 | + | client <- ports.WindowMapped{ID: 3} | |||
| 64 | + | scene(t, scenes) | |||
| 65 | + | moved := places(commands) | |||
| 66 | + | if len(moved) != 1 || moved[0].ID != 2 || (moved[0].X == first[0].X && moved[0].Y == first[0].Y) { | |||
| 67 | + | t.Fatal(first, moved) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Unmap prunes it: mapping again places from nothing. | |||
| 71 | + | client <- ports.WindowUnmapped{ID: 2} | |||
| 72 | + | scene(t, scenes) | |||
| 73 | + | places(commands) | |||
| 74 | + | client <- ports.WindowMapped{ID: 2, Positioned: true} | |||
| 75 | + | scene(t, scenes) | |||
| 76 | + | again := places(commands) | |||
| 77 | + | if len(again) != 1 || again[0].ID != 2 { | |||
| 78 | + | t.Fatal(again) | |||
| 79 | + | } | |||
| 80 | + | } | |||
M internal/core/rules_test.go +44 -0
| @@ -411,3 +411,47 @@ func TestRuleWidthWithFixedOverflow(t *testing.T) { | |||||
| 411 | t.Fatalf("%+v", s.Windows) | 411 | t.Fatalf("%+v", s.Windows) | ||
| 412 | } | 412 | } | ||
| 413 | } | 413 | } | ||
| 414 | + | ||||
| 415 | + | func TestMapAtOutputPlacesOnThatOutput(t *testing.T) { | |||
| 416 | + | r := startMulti(t, nil, left, right) | |||
| 417 | + | r.mapApp(t, 1, "term") | |||
| 418 | + | // DP-2 starts at layout x=200: a game mapping at its rect goes there. | |||
| 419 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 420 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-2" { | |||
| 421 | + | t.Fatalf("not on DP-2: %+v", w) | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func TestMapAtOutputKeepsRuleMonitor(t *testing.T) { | |||
| 426 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 427 | + | c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Monitor = "DP-1" })} | |||
| 428 | + | }, left, right) | |||
| 429 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 430 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" { | |||
| 431 | + | t.Fatalf("rule monitor lost to the hint: %+v", w) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | func TestMapAtOutputKeepsRuleWorkspace(t *testing.T) { | |||
| 436 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 437 | + | c.Rules = []ports.WindowRule{winRule("g", "game", func(r *ports.WindowRule) { r.Workspace = "1" })} | |||
| 438 | + | }, left, right) | |||
| 439 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "game", HasAt: true, AtX: 200, AtY: 0} | |||
| 440 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Output != "DP-1" { | |||
| 441 | + | t.Fatalf("rule workspace lost to the hint: %+v", w) | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestRuleMatchesSteamAppClass(t *testing.T) { | |||
| 446 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 447 | + | c.Rules = []ports.WindowRule{winRule("steam", `steam_app_\d+`, func(r *ports.WindowRule) { r.Floating = ruleOn() })} | |||
| 448 | + | }, left) | |||
| 449 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "steam_app_892970", Positioned: true} | |||
| 450 | + | if w := stWindow(r.stateWith(t, 1), 1); !w.Floating { | |||
| 451 | + | t.Fatalf("steam_app rule did not match: %+v", w) | |||
| 452 | + | } | |||
| 453 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "steam_app_x", Positioned: true} | |||
| 454 | + | if w := stWindow(r.stateWith(t, 2), 2); w.Floating { | |||
| 455 | + | t.Fatalf("non-numeric id matched: %+v", w) | |||
| 456 | + | } | |||
| 457 | + | } | |||
M internal/core/scene.go +12 -1
| @@ -20,7 +20,7 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 20 | // layout is what is drawn; settled (same indexes) is where the | 20 | // layout is what is drawn; settled (same indexes) is where the | ||
| 21 | // windows are going, and alone sizes the configures. | 21 | // windows are going, and alone sizes the configures. | ||
| 22 | layout, settled := sc.shown, sc.settledLayout | 22 | layout, settled := sc.shown, sc.settledLayout | ||
| 23 | - | scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+1), Layers: shownLayers(sc)} | 23 | + | scene = ports.Scene{Security: c.security, Output: sc.name(), OutputWidth: o.W, OutputHeight: o.H, WorkspaceClip: clip, Scale: sc.scale, Transform: sc.transform, Off: sc.off, Background: c.cfg.Background.Color, Border: ports.Border{Radius: c.cfg.Border.Radius, Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, Windows: make([]ports.SceneWindow, 0, len(layout)+len(c.popupOrder)+len(c.unmanagedOrder)+1), Layers: shownLayers(sc)} |
| 24 | if !sc.mon.previewing() { | 24 | if !sc.mon.previewing() { | ||
| 25 | // Tiles stop at the panels; the overview lays out previews over | 25 | // Tiles stop at the panels; the overview lays out previews over | ||
| 26 | // the whole output. | 26 | // the whole output. | ||
| @@ -116,8 +116,19 @@ func (c *Core) sceneFor(ctx context.Context, i int, sc *screen, only *screen, ca | |||||
| 116 | } | 116 | } | ||
| 117 | c.configures.mark(v) | 117 | c.configures.mark(v) | ||
| 118 | } | 118 | } | ||
| 119 | + | if !p.Hidden && p.Preview == 0 && c.windows.lookup(p.ID).client.Positioned { | |||
| 120 | + | cr := c.clientRect(ps) | |||
| 121 | + | at := Rect{X: sc.x + cr.X, Y: sc.y + cr.Y} | |||
| 122 | + | if old, ok := c.placed[p.ID]; !ok || old != at { | |||
| 123 | + | if err := c.command(ctx, ports.PlaceWindow{ID: p.ID, X: at.X, Y: at.Y}); err != nil { | |||
| 124 | + | return ports.Scene{}, err | |||
| 125 | + | } | |||
| 126 | + | c.placed[p.ID] = at | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 119 | } | 129 | } | ||
| 120 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | 130 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | ||
| 131 | + | scene.Windows = c.sceneUnmanaged(scene.Windows, sc) | |||
| 121 | if icon, ok := c.dataDragIconOn(i, sc); ok { | 132 | if icon, ok := c.dataDragIconOn(i, sc); ok { | ||
| 122 | // The drag icon is over everything, popups and layers included. | 133 | // The drag icon is over everything, popups and layers included. | ||
| 123 | scene.Windows = append(scene.Windows, icon) | 134 | scene.Windows = append(scene.Windows, icon) | ||
M internal/core/spawn_placement.go +9 -1
| @@ -139,7 +139,15 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 139 | } | 139 | } | ||
| 140 | } | 140 | } | ||
| 141 | if s, _ := c.screenOf(v.ID); s == nil { | 141 | 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 { | |||
| 143 | c.placeByRule(v, eff) | 151 | c.placeByRule(v, eff) | ||
| 144 | return | 152 | return | ||
| 145 | } | 153 | } | ||
M internal/core/transition.go +20 -0
| @@ -400,11 +400,17 @@ func (c *Core) appearFrom(rm *rectMotion, p Placement, old *rectMotion, now time | |||||
| 400 | // client is configured once, to its final size. | 400 | // client is configured once, to its final size. | ||
| 401 | func (c *Core) mapWindow(v ports.WindowMapped) { | 401 | func (c *Core) mapWindow(v ports.WindowMapped) { | ||
| 402 | now := c.now() | 402 | now := c.now() | ||
| 403 | + | if v.Fullscreen && v.Parent == 0 { | |||
| 404 | + | // Mapped fullscreen: a tile of its own, whatever size class the | |||
| 405 | + | // client reported (a fixed-size game is not a float). | |||
| 406 | + | v.Floating, v.Width, v.Height = false, 0, 0 | |||
| 407 | + | } | |||
| 403 | c.windows.mapped(v, now) | 408 | c.windows.mapped(v, now) | ||
| 404 | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | 409 | if s, _ := c.screenOf(v.ID); s != nil || !c.animOn() || c.security.Protected { | ||
| 405 | // Mapped before (a re-map keeps its place), animations off or a | 410 | // Mapped before (a re-map keeps its place), animations off or a | ||
| 406 | // session lock: nothing to animate. | 411 | // session lock: nothing to animate. | ||
| 407 | c.placement.place(c, v) | 412 | c.placement.place(c, v) | ||
| 413 | + | c.initialFullscreen(v) | |||
| 408 | return | 414 | return | ||
| 409 | } | 415 | } | ||
| 410 | shots := c.snapshot(now) | 416 | shots := c.snapshot(now) | ||
| @@ -416,10 +422,23 @@ func (c *Core) mapWindow(v ports.WindowMapped) { | |||||
| 416 | } | 422 | } | ||
| 417 | } | 423 | } | ||
| 418 | c.placement.place(c, v) | 424 | c.placement.place(c, v) | ||
| 425 | + | c.initialFullscreen(v) | |||
| 419 | c.transition(shots, now) | 426 | c.transition(shots, now) | ||
| 420 | c.appearMapped(v.ID, now) | 427 | c.appearMapped(v.ID, now) | ||
| 421 | } | 428 | } | ||
| 422 | 429 | ||||
| 430 | + | // initialFullscreen makes a window that mapped fullscreen cover its screen. | |||
| 431 | + | // The fullscreen grace does not apply: it is the state the client opened | |||
| 432 | + | // with, not a later request. A re-map (screen already known) is left alone. | |||
| 433 | + | func (c *Core) initialFullscreen(v ports.WindowMapped) { | |||
| 434 | + | if !v.Fullscreen || v.Remap { | |||
| 435 | + | return | |||
| 436 | + | } | |||
| 437 | + | if s, _ := c.screenOf(v.ID); s != nil { | |||
| 438 | + | s.mon.SetFullscreen(v.ID, true) | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 423 | // appearMapped starts the entrance of a window that just mapped when it | 442 | // appearMapped starts the entrance of a window that just mapped when it | ||
| 424 | // shows on its screen's current workspace as a tile or an ordinary float: | 443 | // shows on its screen's current workspace as a tile or an ordinary float: | ||
| 425 | // not with a preview open or a session lock, not fullscreen (it keeps | 444 | // not with a preview open or a session lock, not fullscreen (it keeps | ||
| @@ -453,6 +472,7 @@ func (c *Core) unmapWindow(v ports.WindowUnmapped) { | |||||
| 453 | // The window is unmapped: a toplevel that maps again keeps its ID and | 472 | // The window is unmapped: a toplevel that maps again keeps its ID and | ||
| 454 | // must be configured from nothing (a leaving entry keeps no configure). | 473 | // must be configured from nothing (a leaving entry keeps no configure). | ||
| 455 | c.configures.forget(v.ID) | 474 | c.configures.forget(v.ID) | ||
| 475 | + | delete(c.placed, v.ID) | |||
| 456 | sc, w := c.screenOf(v.ID) | 476 | sc, w := c.screenOf(v.ID) | ||
| 457 | if sc == nil { | 477 | if sc == nil { | ||
| 458 | return | 478 | return | ||
A internal/core/unmanaged.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "slices" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // unmanagedState is a mapped override-redirect X11 window: an absolute layout | |||
| 11 | + | // rect chosen by the client, no parent, never keyboard-focused. rect is in | |||
| 12 | + | // global logical coordinates. | |||
| 13 | + | type unmanagedState struct { | |||
| 14 | + | id WindowID | |||
| 15 | + | rect Rect | |||
| 16 | + | noInput bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // unmanagedEvent applies an unmanaged window event. changed is true when the | |||
| 20 | + | // scene or the pointer target may differ. | |||
| 21 | + | func (c *Core) unmanagedEvent(ctx context.Context, ev ports.ClientEvent) (changed bool, err error) { | |||
| 22 | + | switch v := ev.(type) { | |||
| 23 | + | case ports.UnmanagedMapped: | |||
| 24 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |||
| 25 | + | if u := c.unmanaged[v.ID]; u != nil { | |||
| 26 | + | if u.rect == r && u.noInput == v.NoInput { | |||
| 27 | + | return false, nil | |||
| 28 | + | } | |||
| 29 | + | u.rect, u.noInput = r, v.NoInput | |||
| 30 | + | } else { | |||
| 31 | + | c.unmanaged[v.ID] = &unmanagedState{id: v.ID, rect: r, noInput: v.NoInput} | |||
| 32 | + | c.unmanagedOrder = append(c.unmanagedOrder, v.ID) | |||
| 33 | + | } | |||
| 34 | + | case ports.UnmanagedMoved: | |||
| 35 | + | u := c.unmanaged[v.ID] | |||
| 36 | + | r := Rect{X: v.X, Y: v.Y, W: v.Width, H: v.Height} | |||
| 37 | + | if u == nil || u.rect == r { | |||
| 38 | + | return false, nil | |||
| 39 | + | } | |||
| 40 | + | u.rect = r | |||
| 41 | + | case ports.UnmanagedUnmapped: | |||
| 42 | + | if c.unmanaged[v.ID] == nil { | |||
| 43 | + | return false, nil | |||
| 44 | + | } | |||
| 45 | + | delete(c.unmanaged, v.ID) | |||
| 46 | + | c.unmanagedOrder = slices.DeleteFunc(c.unmanagedOrder, func(id WindowID) bool { return id == v.ID }) | |||
| 47 | + | c.windows.drop(v.ID) | |||
| 48 | + | if err := c.dataDragGone(ctx, v.ID); err != nil { | |||
| 49 | + | return false, err | |||
| 50 | + | } | |||
| 51 | + | // rehit does nothing while a button grab or drag is held: do not | |||
| 52 | + | // leave the pointer and grab on the dead window. | |||
| 53 | + | if c.grab == v.ID { | |||
| 54 | + | c.grab = 0 | |||
| 55 | + | } | |||
| 56 | + | if c.pointer == v.ID { | |||
| 57 | + | c.pointer = 0 | |||
| 58 | + | if err := c.pointerFocus(ctx, ports.PointerFocus{}); err != nil { | |||
| 59 | + | return false, err | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | // The window under a still pointer may have changed. | |||
| 64 | + | return true, c.rehit(ctx) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // unmanagedScreen is the screen showing u: the one containing its origin, or | |||
| 68 | + | // the one the pointer clamp would pick when the origin is in a hole. | |||
| 69 | + | func (c *Core) unmanagedScreen(u *unmanagedState) *screen { | |||
| 70 | + | x, y := float64(u.rect.X), float64(u.rect.Y) | |||
| 71 | + | i := c.screenAt(x, y) | |||
| 72 | + | if i < 0 { | |||
| 73 | + | cx, cy := c.clampPointer(x, y, x, y) | |||
| 74 | + | i = c.screenAt(cx, cy) | |||
| 75 | + | } | |||
| 76 | + | if i < 0 { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | return c.screens[i] | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // sceneUnmanaged appends the unmanaged windows shown on sc, bottom to top, | |||
| 83 | + | // drawn as popups over the layers. They are hidden while sc previews. | |||
| 84 | + | func (c *Core) sceneUnmanaged(out []ports.SceneWindow, sc *screen) []ports.SceneWindow { | |||
| 85 | + | if len(c.unmanagedOrder) == 0 || sc.mon.previewing() { | |||
| 86 | + | return out | |||
| 87 | + | } | |||
| 88 | + | for _, id := range c.unmanagedOrder { | |||
| 89 | + | u := c.unmanaged[id] | |||
| 90 | + | if u == nil || c.unmanagedScreen(u) != sc { | |||
| 91 | + | continue | |||
| 92 | + | } | |||
| 93 | + | r := u.rect | |||
| 94 | + | r.X, r.Y = r.X-sc.x, r.Y-sc.y | |||
| 95 | + | out = append(out, ports.SceneWindow{ID: id, Rect: r, Popup: true, OverLayers: true}) | |||
| 96 | + | } | |||
| 97 | + | return out | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // unmanagedAt is the topmost unmanaged window taking the pointer at the | |||
| 101 | + | // screen-local point. None while the pointer is locked or the screen previews. | |||
| 102 | + | func (c *Core) unmanagedAt(sc *screen, lx, ly float64) (WindowID, float64, float64) { | |||
| 103 | + | if len(c.unmanagedOrder) == 0 || c.constraint.Mode == ports.ConstraintLock || sc.mon.previewing() { | |||
| 104 | + | return 0, 0, 0 | |||
| 105 | + | } | |||
| 106 | + | for i := len(c.unmanagedOrder) - 1; i >= 0; i-- { | |||
| 107 | + | u := c.unmanaged[c.unmanagedOrder[i]] | |||
| 108 | + | if u == nil || u.noInput || c.unmanagedScreen(u) != sc { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | x, y := lx-float64(u.rect.X-sc.x), ly-float64(u.rect.Y-sc.y) | |||
| 112 | + | if x >= 0 && x < float64(u.rect.W) && y >= 0 && y < float64(u.rect.H) && c.acceptsInput(u.id, x, y) { | |||
| 113 | + | return u.id, x, y | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return 0, 0, 0 | |||
| 117 | + | } | |||
A internal/core/unmanaged_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func unmanagedScene(t *testing.T, c *Core, i int) ports.Scene { | |||
| 11 | + | t.Helper() | |||
| 12 | + | s, err := c.sceneFor(context.Background(), i, c.screens[i], nil, nil) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | return s | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func mapUnmanaged(t *testing.T, c *Core, id WindowID, x, y, w, h int, noInput bool) { | |||
| 20 | + | t.Helper() | |||
| 21 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: id, X: x, Y: y, Width: w, Height: h, NoInput: noInput}) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestUnmanagedScene(t *testing.T) { | |||
| 25 | + | c := publishRig(t, 2) | |||
| 26 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |||
| 27 | + | mapUnmanaged(t, c, 101, 5, 5, 10, 10, true) | |||
| 28 | + | b := unmanagedScene(t, c, 1) | |||
| 29 | + | last := b.Windows[len(b.Windows)-1] | |||
| 30 | + | if last.ID != 100 || last.Rect != (Rect{X: 10, Y: 20, W: 50, H: 40}) || !last.Popup || !last.OverLayers { | |||
| 31 | + | t.Fatalf("screen B top window %+v", last) | |||
| 32 | + | } | |||
| 33 | + | a := unmanagedScene(t, c, 0) | |||
| 34 | + | if got := a.Windows[len(a.Windows)-1]; got.ID != 101 || got.Rect.X != 5 { | |||
| 35 | + | t.Fatalf("screen A top window %+v", got) | |||
| 36 | + | } | |||
| 37 | + | c.clientEvent(context.Background(), ports.UnmanagedMoved{ID: 100, X: 20, Y: 20, Width: 50, Height: 40}) | |||
| 38 | + | if got := unmanagedScene(t, c, 0).Windows; got[len(got)-2].ID != 100 || got[len(got)-2].Rect.X != 20 { | |||
| 39 | + | t.Fatalf("moved window not on screen A: %+v", got) | |||
| 40 | + | } | |||
| 41 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |||
| 42 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 101}) | |||
| 43 | + | if len(c.unmanagedOrder) != 0 || len(c.unmanaged) != 0 { | |||
| 44 | + | t.Fatal("state left after unmap") | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestUnmanagedHiddenWhilePreviewing(t *testing.T) { | |||
| 49 | + | c := publishRig(t, 1) | |||
| 50 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |||
| 51 | + | c.cur().mon.ov.open = true | |||
| 52 | + | if !c.cur().mon.previewing() { | |||
| 53 | + | t.Fatal("setup: not previewing") | |||
| 54 | + | } | |||
| 55 | + | for _, w := range unmanagedScene(t, c, 0).Windows { | |||
| 56 | + | if w.ID == 100 { | |||
| 57 | + | t.Fatal("unmanaged window drawn in the overview") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if id, _, _ := c.hit(20, 20); id == 100 { | |||
| 61 | + | t.Fatal("unmanaged window hit in the overview") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestUnmanagedHit(t *testing.T) { | |||
| 66 | + | c := publishRig(t, 2) | |||
| 67 | + | mapUnmanaged(t, c, 100, 310, 20, 50, 40, false) | |||
| 68 | + | mapUnmanaged(t, c, 101, 320, 30, 10, 10, false) | |||
| 69 | + | mapUnmanaged(t, c, 102, 340, 30, 10, 10, true) | |||
| 70 | + | if id, x, y := c.hit(325, 35); id != 101 || x != 5 || y != 5 { | |||
| 71 | + | t.Fatalf("topmost: %d %v %v", id, x, y) | |||
| 72 | + | } | |||
| 73 | + | if id, _, _ := c.hit(345, 35); id != 100 { | |||
| 74 | + | t.Fatalf("noInput must be skipped: %d", id) | |||
| 75 | + | } | |||
| 76 | + | c.windows.setRegion(ports.InputRegionChanged{ID: 100, Rects: []Rect{{X: 0, Y: 0, W: 5, H: 5}}}) | |||
| 77 | + | if id, _, _ := c.hit(345, 35); id == 100 { | |||
| 78 | + | t.Fatal("outside the input region") | |||
| 79 | + | } | |||
| 80 | + | c.constraint.Mode = ports.ConstraintLock | |||
| 81 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |||
| 82 | + | t.Fatal("locked pointer must not move to an unmanaged window") | |||
| 83 | + | } | |||
| 84 | + | c.constraint.Mode = 0 | |||
| 85 | + | c.security.Protected = true | |||
| 86 | + | if id, _, _ := c.hit(325, 35); id == 101 { | |||
| 87 | + | t.Fatal("protected session hit an unmanaged window") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func unmanagedRig(t testing.TB, n int) *Core { | |||
| 92 | + | c := publishRig(t, 1) | |||
| 93 | + | for i := range n { | |||
| 94 | + | c.clientEvent(context.Background(), ports.UnmanagedMapped{ID: WindowID(100 + i), X: 10 * i, Y: 10, Width: 30, Height: 30}) | |||
| 95 | + | } | |||
| 96 | + | return c | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func publishOnce(t testing.TB, c *Core) { | |||
| 100 | + | if err := c.publish(context.Background()); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | <-c.ch.Scenes | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // TestUnmanagedPublishAllocations: three unmanaged windows cost no more than | |||
| 107 | + | // none (the scene window slice is presized for them). | |||
| 108 | + | func TestUnmanagedPublishAllocations(t *testing.T) { | |||
| 109 | + | base, with := unmanagedRig(t, 0), unmanagedRig(t, 3) | |||
| 110 | + | publishOnce(t, base) | |||
| 111 | + | publishOnce(t, with) | |||
| 112 | + | a := testing.AllocsPerRun(50, func() { publishOnce(t, base) }) | |||
| 113 | + | b := testing.AllocsPerRun(50, func() { publishOnce(t, with) }) | |||
| 114 | + | if b > a { | |||
| 115 | + | t.Fatalf("publish allocs with unmanaged = %v, without = %v", b, a) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestUnmanagedHitAllocations(t *testing.T) { | |||
| 120 | + | c := unmanagedRig(t, 3) | |||
| 121 | + | if n := testing.AllocsPerRun(100, func() { c.hit(25, 20); c.hit(250, 150) }); n != 0 { | |||
| 122 | + | t.Fatalf("hit allocs = %v", n) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestUnmanagedUnmapDuringGrabClearsPointer(t *testing.T) { | |||
| 127 | + | c := publishRig(t, 1) | |||
| 128 | + | mapUnmanaged(t, c, 100, 10, 10, 50, 40, false) | |||
| 129 | + | cmds := make(chan ports.ClientCommand, 64) | |||
| 130 | + | c.ch.Commands = cmds | |||
| 131 | + | c.pointer, c.grab = 100, 100 | |||
| 132 | + | c.clientEvent(context.Background(), ports.UnmanagedUnmapped{ID: 100}) | |||
| 133 | + | if c.pointer == 100 || c.grab != 0 { | |||
| 134 | + | t.Fatalf("pointer %d grab %d left on the dead window", c.pointer, c.grab) | |||
| 135 | + | } | |||
| 136 | + | var focus bool | |||
| 137 | + | for len(cmds) > 0 { | |||
| 138 | + | if f, ok := (<-cmds).(ports.PointerFocus); ok && f == (ports.PointerFocus{}) { | |||
| 139 | + | focus = true | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | if !focus { | |||
| 143 | + | t.Fatal("pointer focus not reset") | |||
| 144 | + | } | |||
| 145 | + | } | |||
M internal/ports/client_commands.go +10 -0
| @@ -47,6 +47,16 @@ type ConfigureWindow struct { | |||||
| 47 | 47 | ||||
| 48 | func (ConfigureWindow) clientCommand() {} | 48 | func (ConfigureWindow) clientCommand() {} | ||
| 49 | 49 | ||||
| 50 | + | // PlaceWindow carries core → wayland the position of a Positioned window's | |||
| 51 | + | // client rect, in layout logical coordinates (output origin included). It | |||
| 52 | + | // follows the ConfigureWindow of a frame and is sent once per change. | |||
| 53 | + | type PlaceWindow struct { | |||
| 54 | + | ID WindowID | |||
| 55 | + | X, Y int | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (PlaceWindow) clientCommand() {} | |||
| 59 | + | ||||
| 50 | // OutputPlacement is one output in the global layout. X, Y, Width and | 60 | // OutputPlacement is one output in the global layout. X, Y, Width and | ||
| 51 | // Height are logical (after Transform: they swap for 90/270); | 61 | // Height are logical (after Transform: they swap for 90/270); | ||
| 52 | // physical = logical × Scale. Info.Width and Info.Height are the physical | 62 | // physical = logical × Scale. Info.Width and Info.Height are the physical | ||
M internal/ports/client_events.go +37 -0
| @@ -20,12 +20,24 @@ type WindowMapped struct { | |||||
| 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | 20 | // xdg-foreign), 0 when none. A dialog of a fullscreen window shows over | ||
| 21 | // it. | 21 | // it. | ||
| 22 | Parent WindowID | 22 | Parent WindowID | ||
| 23 | + | // Positioned windows are placed by core: it sends PlaceWindow with the | |||
| 24 | + | // client rect origin. Set only by the X11 host, false for xdg. | |||
| 25 | + | Positioned bool | |||
| 23 | // Remap is set when the toplevel mapped before and was unmapped (a tray | 26 | // Remap is set when the toplevel mapped before and was unmapped (a tray | ||
| 24 | // app restored): window rules only place its first map. | 27 | // app restored): window rules only place its first map. | ||
| 25 | Remap bool | 28 | Remap bool | ||
| 26 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | 29 | // Modal is set for a dialog that blocks its parent (xdg_dialog_v1): | ||
| 27 | // focusing the parent focuses the dialog. | 30 | // focusing the parent focuses the dialog. | ||
| 28 | Modal bool | 31 | Modal bool | ||
| 32 | + | // Fullscreen is set when the client asked for fullscreen before mapping | |||
| 33 | + | // (X11 _NET_WM_STATE_FULLSCREEN): core maps it as a tile covering its | |||
| 34 | + | // screen, without the fullscreen grace of later requests. | |||
| 35 | + | Fullscreen bool | |||
| 36 | + | // HasAt asks for the screen containing the layout point (AtX, AtY) when | |||
| 37 | + | // no rule sets a monitor: the X11 host sets it for a window that maps at | |||
| 38 | + | // exactly one output's rect. | |||
| 39 | + | HasAt bool | |||
| 40 | + | AtX, AtY int | |||
| 29 | } | 41 | } | ||
| 30 | 42 | ||||
| 31 | // SlotEnv is the environment variable neferwl sets on processes it spawns | 43 | // SlotEnv is the environment variable neferwl sets on processes it spawns | ||
| @@ -49,6 +61,31 @@ type WindowUnmapped struct{ ID WindowID } | |||||
| 49 | 61 | ||||
| 50 | func (WindowUnmapped) clientEvent() {} | 62 | func (WindowUnmapped) clientEvent() {} | ||
| 51 | 63 | ||||
| 64 | + | // UnmanagedMapped carries wayland → core that an override-redirect X11 window | |||
| 65 | + | // shows. The rect is its place in the layout (logical coordinates). NoInput | |||
| 66 | + | // windows (tooltips, drag icons, notifications) never take the pointer. | |||
| 67 | + | type UnmanagedMapped struct { | |||
| 68 | + | ID WindowID | |||
| 69 | + | X, Y, Width, Height int | |||
| 70 | + | NoInput bool | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (UnmanagedMapped) clientEvent() {} | |||
| 74 | + | ||||
| 75 | + | // UnmanagedMoved carries wayland → core a move and/or resize of a mapped | |||
| 76 | + | // unmanaged window. | |||
| 77 | + | type UnmanagedMoved struct { | |||
| 78 | + | ID WindowID | |||
| 79 | + | X, Y, Width, Height int | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (UnmanagedMoved) clientEvent() {} | |||
| 83 | + | ||||
| 84 | + | // UnmanagedUnmapped carries wayland → core that an unmanaged window is gone. | |||
| 85 | + | type UnmanagedUnmapped struct{ ID WindowID } | |||
| 86 | + | ||||
| 87 | + | func (UnmanagedUnmapped) clientEvent() {} | |||
| 88 | + | ||||
| 52 | // ShortcutsInhibit carries wayland → core that a window asks for (Active) | 89 | // ShortcutsInhibit carries wayland → core that a window asks for (Active) | ||
| 53 | // or stops asking for all keys, compositor binds included, while it has | 90 | // or stops asking for all keys, compositor binds included, while it has | ||
| 54 | // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1). | 91 | // keyboard focus (zwp_keyboard_shortcuts_inhibit_v1). | ||
M internal/ports/config.go +35 -3
| @@ -101,9 +101,8 @@ type Config struct { | |||||
| 101 | Command []string | 101 | Command []string | ||
| 102 | AutoOpen string | 102 | AutoOpen string | ||
| 103 | } | 103 | } | ||
| 104 | - | // Xwayland is the xwayland-satellite binary serving X11 clients; empty | 104 | + | // Xwayland selects the X11 launcher. |
| 105 | - | // disables X11. | 105 | + | Xwayland XwaylandConfig |
| 106 | - | Xwayland string | |||
| 107 | // Startup are commands run once when the session starts, in order. | 106 | // Startup are commands run once when the session starts, in order. | ||
| 108 | // One startup line may hold several, split by SplitCommands. | 107 | // One startup line may hold several, split by SplitCommands. | ||
| 109 | Startup [][]string | 108 | Startup [][]string | ||
| @@ -298,3 +297,36 @@ type WindowRule struct { | |||||
| 298 | // (1 to 16; 0 leaves it automatic). | 297 | // (1 to 16; 0 leaves it automatic). | ||
| 299 | Cells int | 298 | Cells int | ||
| 300 | } | 299 | } | ||
| 300 | + | ||||
| 301 | + | // XwaylandBackend selects how X11 clients are served. | |||
| 302 | + | type XwaylandBackend uint8 | |||
| 303 | + | ||||
| 304 | + | const ( | |||
| 305 | + | XwaylandOff XwaylandBackend = iota | |||
| 306 | + | XwaylandSatellite | |||
| 307 | + | XwaylandBuiltin | |||
| 308 | + | ) | |||
| 309 | + | ||||
| 310 | + | // XwaylandConfig selects the launcher and its executable. | |||
| 311 | + | type XwaylandConfig struct { | |||
| 312 | + | Backend XwaylandBackend | |||
| 313 | + | Binary string | |||
| 314 | + | // 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 | + | } | |||