feat(wayland): apply compositor cursor overrides over client cursors
- commit
- dbc10e2093b2ac0bb0454ec41021a0b00ed12d48
- author
- brice · · 2026-10-21 05:18 UTC
- committer
- brice · · 2026-10-21 07:11 UTC
- parent
- a8dbdb2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +81 -3
M internal/adapters/wayland/cursor.go +26 -2
| @@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange { | |||
| 138 | 138 | return ports.CursorChange{Image: &img, Scale: scale} | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | - | // setCursor queues a cursor change; only the latest is delivered. | |
| 141 | + | // setCursor remembers the client cursor, forwarding it unless overridden. | |
| 142 | 142 | func (s *Server) setCursor(c ports.CursorChange) { | |
| 143 | - | if s.protected() || s.channels.Cursors == nil { | |
| 143 | + | if s.protected() { | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | s.cursorClient = c | |
| 147 | + | if s.cursorOverride == "" { | |
| 148 | + | s.queueCursor(c) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (s *Server) setCursorOverride(shape string) { | |
| 153 | + | if s.protected() || shape == s.cursorOverride { | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | s.cursorOverride = shape | |
| 157 | + | s.log.Debug().Str("shape", shape).Msg("cursor override") | |
| 158 | + | if shape == "" { | |
| 159 | + | s.queueCursor(s.cursorClient) | |
| 160 | + | } else { | |
| 161 | + | s.queueCursor(ports.CursorChange{Shape: shape}) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // queueCursor queues a cursor change; only the latest is delivered. | |
| 166 | + | func (s *Server) queueCursor(c ports.CursorChange) { | |
| 167 | + | if s.channels.Cursors == nil { | |
| 144 | 168 | return | |
| 145 | 169 | } | |
| 146 | 170 | s.cursorMu.Lock() | |
| @@ -138,9 +138,33 @@ func (s *surface) cursorImage() ports.CursorChange { | |||||
| 138 | return ports.CursorChange{Image: &img, Scale: scale} | 138 | return ports.CursorChange{Image: &img, Scale: scale} | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | - | // setCursor queues a cursor change; only the latest is delivered. | 141 | + | // setCursor remembers the client cursor, forwarding it unless overridden. |
| 142 | func (s *Server) setCursor(c ports.CursorChange) { | 142 | func (s *Server) setCursor(c ports.CursorChange) { | ||
| 143 | - | if s.protected() || s.channels.Cursors == nil { | 143 | + | if s.protected() { |
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | s.cursorClient = c | |||
| 147 | + | if s.cursorOverride == "" { | |||
| 148 | + | s.queueCursor(c) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (s *Server) setCursorOverride(shape string) { | |||
| 153 | + | if s.protected() || shape == s.cursorOverride { | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | s.cursorOverride = shape | |||
| 157 | + | s.log.Debug().Str("shape", shape).Msg("cursor override") | |||
| 158 | + | if shape == "" { | |||
| 159 | + | s.queueCursor(s.cursorClient) | |||
| 160 | + | } else { | |||
| 161 | + | s.queueCursor(ports.CursorChange{Shape: shape}) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // queueCursor queues a cursor change; only the latest is delivered. | |||
| 166 | + | func (s *Server) queueCursor(c ports.CursorChange) { | |||
| 167 | + | if s.channels.Cursors == nil { | |||
| 144 | return | 168 | return | ||
| 145 | } | 169 | } | ||
| 146 | s.cursorMu.Lock() | 170 | s.cursorMu.Lock() | ||
M internal/adapters/wayland/cursor_test.go +48 -0
| @@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) { | |||
| 83 | 83 | if got := nextCursor(t, cursors); got.Shape != "pointer" { | |
| 84 | 84 | t.Fatalf("shape: %+v", got) | |
| 85 | 85 | } | |
| 86 | + | commands <- ports.CursorOverride{Shape: "ew-resize"} | |
| 87 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) { | |
| 88 | + | t.Fatalf("override: %+v", got) | |
| 89 | + | } | |
| 90 | + | requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText)) | |
| 91 | + | if err := c.Roundtrip(); err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | select { | |
| 95 | + | case got := <-cursors: | |
| 96 | + | t.Fatalf("client cursor forwarded during override: %+v", got) | |
| 97 | + | case <-time.After(50 * time.Millisecond): | |
| 98 | + | } | |
| 99 | + | commands <- ports.CursorOverride{} | |
| 100 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) { | |
| 101 | + | t.Fatalf("restored client cursor: %+v", got) | |
| 102 | + | } | |
| 86 | 103 | commands <- ports.PointerFocus{ID: 0} | |
| 87 | 104 | if got := nextCursor(t, cursors); got != (ports.CursorChange{}) { | |
| 88 | 105 | t.Fatalf("leave: %+v, want the arrow", got) | |
| @@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) { | |||
| 205 | 222 | requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0)) | |
| 206 | 223 | expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole)) | |
| 207 | 224 | } | |
| 225 | + | ||
| 226 | + | func TestCursorOverrideProtection(t *testing.T) { | |
| 227 | + | s, _, commands, cursors, _ := cursorServer(t) | |
| 228 | + | state := installSecurity(t, s) | |
| 229 | + | commands <- ports.CursorOverride{Shape: "ns-resize"} | |
| 230 | + | if got := nextCursor(t, cursors); got.Shape != "ns-resize" { | |
| 231 | + | t.Fatalf("override: %+v", got) | |
| 232 | + | } | |
| 233 | + | protect(t, s, state, 1, true) | |
| 234 | + | if !s.display.Do(func() { | |
| 235 | + | s.resetProtectionInput() | |
| 236 | + | s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}}) | |
| 237 | + | if s.cursorOverride != "" { | |
| 238 | + | t.Errorf("protected override: %q", s.cursorOverride) | |
| 239 | + | } | |
| 240 | + | }) { | |
| 241 | + | t.Fatal("display stopped") | |
| 242 | + | } | |
| 243 | + | select { | |
| 244 | + | case got := <-cursors: | |
| 245 | + | t.Fatalf("protected cursor forwarded: %+v", got) | |
| 246 | + | case <-time.After(50 * time.Millisecond): | |
| 247 | + | } | |
| 248 | + | protect(t, s, state, 2, false) | |
| 249 | + | if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) { | |
| 250 | + | t.Fatal("display stopped") | |
| 251 | + | } | |
| 252 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) { | |
| 253 | + | t.Fatalf("override survived protection: %+v", got) | |
| 254 | + | } | |
| 255 | + | } | |
| @@ -83,6 +83,23 @@ func TestCursorShapeFollowsPointerFocus(t *testing.T) { | |||||
| 83 | if got := nextCursor(t, cursors); got.Shape != "pointer" { | 83 | if got := nextCursor(t, cursors); got.Shape != "pointer" { | ||
| 84 | t.Fatalf("shape: %+v", got) | 84 | t.Fatalf("shape: %+v", got) | ||
| 85 | } | 85 | } | ||
| 86 | + | commands <- ports.CursorOverride{Shape: "ew-resize"} | |||
| 87 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "ew-resize"}) { | |||
| 88 | + | t.Fatalf("override: %+v", got) | |||
| 89 | + | } | |||
| 90 | + | requestProtocol(t, c, device, cursorshape.WpCursorShapeDeviceV1RequestSetShape, uint32(3), uint32(cursorshape.WpCursorShapeDeviceV1ShapeText)) | |||
| 91 | + | if err := c.Roundtrip(); err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | select { | |||
| 95 | + | case got := <-cursors: | |||
| 96 | + | t.Fatalf("client cursor forwarded during override: %+v", got) | |||
| 97 | + | case <-time.After(50 * time.Millisecond): | |||
| 98 | + | } | |||
| 99 | + | commands <- ports.CursorOverride{} | |||
| 100 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Shape: "text"}) { | |||
| 101 | + | t.Fatalf("restored client cursor: %+v", got) | |||
| 102 | + | } | |||
| 86 | commands <- ports.PointerFocus{ID: 0} | 103 | commands <- ports.PointerFocus{ID: 0} | ||
| 87 | if got := nextCursor(t, cursors); got != (ports.CursorChange{}) { | 104 | if got := nextCursor(t, cursors); got != (ports.CursorChange{}) { | ||
| 88 | t.Fatalf("leave: %+v, want the arrow", got) | 105 | t.Fatalf("leave: %+v, want the arrow", got) | ||
| @@ -205,3 +222,34 @@ func TestSetCursorRoleError(t *testing.T) { | |||||
| 205 | requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0)) | 222 | requestProtocol(t, c, pointer, wayland.PointerRequestSetCursor, uint32(1), surf, int32(0), int32(0)) | ||
| 206 | expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole)) | 223 | expectProtocolError(t, c, pointer, uint32(wayland.PointerErrorRole)) | ||
| 207 | } | 224 | } | ||
| 225 | + | ||||
| 226 | + | func TestCursorOverrideProtection(t *testing.T) { | |||
| 227 | + | s, _, commands, cursors, _ := cursorServer(t) | |||
| 228 | + | state := installSecurity(t, s) | |||
| 229 | + | commands <- ports.CursorOverride{Shape: "ns-resize"} | |||
| 230 | + | if got := nextCursor(t, cursors); got.Shape != "ns-resize" { | |||
| 231 | + | t.Fatalf("override: %+v", got) | |||
| 232 | + | } | |||
| 233 | + | protect(t, s, state, 1, true) | |||
| 234 | + | if !s.display.Do(func() { | |||
| 235 | + | s.resetProtectionInput() | |||
| 236 | + | s.apply(ports.SecurityCommand{State: *state.Load(), Command: ports.CursorOverride{Shape: "ew-resize"}}) | |||
| 237 | + | if s.cursorOverride != "" { | |||
| 238 | + | t.Errorf("protected override: %q", s.cursorOverride) | |||
| 239 | + | } | |||
| 240 | + | }) { | |||
| 241 | + | t.Fatal("display stopped") | |||
| 242 | + | } | |||
| 243 | + | select { | |||
| 244 | + | case got := <-cursors: | |||
| 245 | + | t.Fatalf("protected cursor forwarded: %+v", got) | |||
| 246 | + | case <-time.After(50 * time.Millisecond): | |||
| 247 | + | } | |||
| 248 | + | protect(t, s, state, 2, false) | |||
| 249 | + | if !s.display.Do(func() { s.setCursor(ports.CursorChange{Hidden: true}) }) { | |||
| 250 | + | t.Fatal("display stopped") | |||
| 251 | + | } | |||
| 252 | + | if got := nextCursor(t, cursors); got != (ports.CursorChange{Hidden: true}) { | |||
| 253 | + | t.Fatalf("override survived protection: %+v", got) | |||
| 254 | + | } | |||
| 255 | + | } | |||
M internal/adapters/wayland/server.go +6 -1
| @@ -288,6 +288,9 @@ type Server struct { | |||
| 288 | 288 | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 289 | 289 | // cursorSurface is the wl_pointer.set_cursor surface in use. | |
| 290 | 290 | cursorSurface *surface | |
| 291 | + | // cursorClient and cursorOverride belong to the display goroutine. | |
| 292 | + | cursorClient ports.CursorChange | |
| 293 | + | cursorOverride string | |
| 291 | 294 | // cursorMu guards only the latest cursor change for forwardCursors. | |
| 292 | 295 | cursorMu sync.Mutex | |
| 293 | 296 | cursorLatest ports.CursorChange | |
| @@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 751 | 754 | // Only input/focus commands require an owner envelope. Config relay | |
| 752 | 755 | // also produces SetKeymap and other epoch-independent commands. | |
| 753 | 756 | switch cmd.(type) { | |
| 754 | - | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, | |
| 757 | + | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride, | |
| 755 | 758 | ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | |
| 756 | 759 | return | |
| 757 | 760 | } | |
| 758 | 761 | } | |
| 759 | 762 | switch c := cmd.(type) { | |
| 763 | + | case ports.CursorOverride: | |
| 764 | + | s.setCursorOverride(c.Shape) | |
| 760 | 765 | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers: | |
| 761 | 766 | s.applyInput(c) | |
| 762 | 767 | case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | |
| @@ -288,6 +288,9 @@ type Server struct { | |||||
| 288 | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 288 | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | ||
| 289 | // cursorSurface is the wl_pointer.set_cursor surface in use. | 289 | // cursorSurface is the wl_pointer.set_cursor surface in use. | ||
| 290 | cursorSurface *surface | 290 | cursorSurface *surface | ||
| 291 | + | // cursorClient and cursorOverride belong to the display goroutine. | |||
| 292 | + | cursorClient ports.CursorChange | |||
| 293 | + | cursorOverride string | |||
| 291 | // cursorMu guards only the latest cursor change for forwardCursors. | 294 | // cursorMu guards only the latest cursor change for forwardCursors. | ||
| 292 | cursorMu sync.Mutex | 295 | cursorMu sync.Mutex | ||
| 293 | cursorLatest ports.CursorChange | 296 | cursorLatest ports.CursorChange | ||
| @@ -751,12 +754,14 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||||
| 751 | // Only input/focus commands require an owner envelope. Config relay | 754 | // Only input/focus commands require an owner envelope. Config relay | ||
| 752 | // also produces SetKeymap and other epoch-independent commands. | 755 | // also produces SetKeymap and other epoch-independent commands. | ||
| 753 | switch cmd.(type) { | 756 | switch cmd.(type) { | ||
| 754 | - | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, | 757 | + | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers, ports.CursorOverride, |
| 755 | ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | 758 | ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | ||
| 756 | return | 759 | return | ||
| 757 | } | 760 | } | ||
| 758 | } | 761 | } | ||
| 759 | switch c := cmd.(type) { | 762 | switch c := cmd.(type) { | ||
| 763 | + | case ports.CursorOverride: | |||
| 764 | + | s.setCursorOverride(c.Shape) | |||
| 760 | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers: | 765 | case ports.PointerFocus, ports.PointerMotionTo, ports.PointerButtonTo, ports.PointerAxisTo, ports.GestureBeginTo, ports.GestureUpdateTo, ports.GestureEndTo, ports.SetKeymap, ports.FocusWindow, ports.ForwardKey, ports.SetModifiers: | ||
| 761 | s.applyInput(c) | 766 | s.applyInput(c) | ||
| 762 | case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | 767 | case ports.DataDragFocus, ports.DataDragMotion, ports.DataDragDrop, ports.DataDragCancel: | ||
M internal/adapters/wayland/session_lock.go +1 -0
| @@ -138,6 +138,7 @@ func (l *sessionLock) orphan() { | |||
| 138 | 138 | } | |
| 139 | 139 | ||
| 140 | 140 | func (s *Server) resetProtectionInput() { | |
| 141 | + | s.cursorOverride = "" | |
| 141 | 142 | s.dndCancelAll() | |
| 142 | 143 | s.changeFocus(0) | |
| 143 | 144 | s.changePointerFocus(0, 0, 0) | |
| @@ -138,6 +138,7 @@ func (l *sessionLock) orphan() { | |||||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | func (s *Server) resetProtectionInput() { | 140 | func (s *Server) resetProtectionInput() { | ||
| 141 | + | s.cursorOverride = "" | |||
| 141 | s.dndCancelAll() | 142 | s.dndCancelAll() | ||
| 142 | s.changeFocus(0) | 143 | s.changeFocus(0) | ||
| 143 | s.changePointerFocus(0, 0, 0) | 144 | s.changePointerFocus(0, 0, 0) | ||