fix(core): focus the lock surface of the focused output once it maps
- commit
- 34fd4a785b539ce19da8555a607fcb587f6464c9
- author
- brice · · 2026-10-02 09:58 UTC
- parent
- 9a9b02b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +46 -6
M internal/core/core.go +1 -0
| @@ -70,6 +70,7 @@ type Core struct { | |||
| 70 | 70 | security ports.SecurityState | |
| 71 | 71 | lockSurfaces []ports.LockSurfacePlacement | |
| 72 | 72 | lockFocus WindowID | |
| 73 | + | lockClicked bool // lockFocus was chosen by a click: it keeps focus | |
| 73 | 74 | inputKeys map[string]bool | |
| 74 | 75 | inputActive bool | |
| 75 | 76 | startup [][]string | |
| @@ -70,6 +70,7 @@ type Core struct { | |||||
| 70 | security ports.SecurityState | 70 | security ports.SecurityState | ||
| 71 | lockSurfaces []ports.LockSurfacePlacement | 71 | lockSurfaces []ports.LockSurfacePlacement | ||
| 72 | lockFocus WindowID | 72 | lockFocus WindowID | ||
| 73 | + | lockClicked bool // lockFocus was chosen by a click: it keeps focus | |||
| 73 | inputKeys map[string]bool | 74 | inputKeys map[string]bool | ||
| 74 | inputActive bool | 75 | inputActive bool | ||
| 75 | startup [][]string | 76 | startup [][]string | ||
M internal/core/security.go +13 -6
| @@ -21,7 +21,7 @@ func (c *Core) syncSecurity() bool { | |||
| 21 | 21 | return false | |
| 22 | 22 | } | |
| 23 | 23 | c.security = state | |
| 24 | - | c.lockSurfaces, c.lockFocus = nil, 0 | |
| 24 | + | c.lockSurfaces, c.lockFocus, c.lockClicked = nil, 0, false | |
| 25 | 25 | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | |
| 26 | 26 | c.pointer, c.grab, c.pointerOutput = 0, 0, "" | |
| 27 | 27 | c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0 | |
| @@ -135,18 +135,25 @@ func (c *Core) validLockID(id WindowID) bool { | |||
| 135 | 135 | return false | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | + | // lockKeyboardFocus prefers the lock surface of the focused output, where the | |
| 139 | + | // user is looking, then any other. A surface the user clicked keeps focus. | |
| 140 | + | // Without a click, focus moves to the focused output's surface once it maps: | |
| 141 | + | // lock clients map their surfaces one by one, in any order. | |
| 138 | 142 | func (c *Core) lockKeyboardFocus() WindowID { | |
| 139 | - | if c.validLockID(c.lockFocus) { | |
| 143 | + | valid := c.validLockID(c.lockFocus) | |
| 144 | + | if valid && c.lockClicked { | |
| 140 | 145 | return c.lockFocus | |
| 141 | 146 | } | |
| 142 | - | c.lockFocus = 0 | |
| 143 | - | // Prefer the focused output, where the user is looking, then any other. | |
| 144 | 147 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | |
| 145 | 148 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | |
| 146 | - | c.lockFocus = s.ID | |
| 149 | + | c.lockFocus, c.lockClicked = s.ID, false | |
| 147 | 150 | return c.lockFocus | |
| 148 | 151 | } | |
| 149 | 152 | } | |
| 153 | + | if valid { | |
| 154 | + | return c.lockFocus | |
| 155 | + | } | |
| 156 | + | c.lockFocus, c.lockClicked = 0, false | |
| 150 | 157 | for _, sc := range c.screens { | |
| 151 | 158 | if s, ok := c.lockSurface(sc); ok { | |
| 152 | 159 | c.lockFocus = s.ID | |
| @@ -273,7 +280,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 273 | 280 | } | |
| 274 | 281 | if c.validLockID(id) { | |
| 275 | 282 | if v.Pressed && c.lockFocus != id { | |
| 276 | - | c.lockFocus = id | |
| 283 | + | c.lockFocus, c.lockClicked = id, true | |
| 277 | 284 | if err := c.publishProtected(ctx); err != nil { | |
| 278 | 285 | return err | |
| 279 | 286 | } | |
| @@ -21,7 +21,7 @@ func (c *Core) syncSecurity() bool { | |||||
| 21 | return false | 21 | return false | ||
| 22 | } | 22 | } | ||
| 23 | c.security = state | 23 | c.security = state | ||
| 24 | - | c.lockSurfaces, c.lockFocus = nil, 0 | 24 | + | c.lockSurfaces, c.lockFocus, c.lockClicked = nil, 0, false |
| 25 | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | 25 | c.pressed, c.buttons, c.inputKeys = map[string]bool{}, map[uint32]bool{}, map[string]bool{} | ||
| 26 | c.pointer, c.grab, c.pointerOutput = 0, 0, "" | 26 | c.pointer, c.grab, c.pointerOutput = 0, 0, "" | ||
| 27 | c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0 | 27 | c.drag, c.swallow, c.mods, c.lastButton = nil, map[uint32]bool{}, 0, 0 | ||
| @@ -135,18 +135,25 @@ func (c *Core) validLockID(id WindowID) bool { | |||||
| 135 | return false | 135 | return false | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | + | // lockKeyboardFocus prefers the lock surface of the focused output, where the | |||
| 139 | + | // user is looking, then any other. A surface the user clicked keeps focus. | |||
| 140 | + | // Without a click, focus moves to the focused output's surface once it maps: | |||
| 141 | + | // lock clients map their surfaces one by one, in any order. | |||
| 138 | func (c *Core) lockKeyboardFocus() WindowID { | 142 | func (c *Core) lockKeyboardFocus() WindowID { | ||
| 139 | - | if c.validLockID(c.lockFocus) { | 143 | + | valid := c.validLockID(c.lockFocus) |
| 144 | + | if valid && c.lockClicked { | |||
| 140 | return c.lockFocus | 145 | return c.lockFocus | ||
| 141 | } | 146 | } | ||
| 142 | - | c.lockFocus = 0 | |||
| 143 | - | // Prefer the focused output, where the user is looking, then any other. | |||
| 144 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | 147 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | ||
| 145 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | 148 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | ||
| 146 | - | c.lockFocus = s.ID | 149 | + | c.lockFocus, c.lockClicked = s.ID, false |
| 147 | return c.lockFocus | 150 | return c.lockFocus | ||
| 148 | } | 151 | } | ||
| 149 | } | 152 | } | ||
| 153 | + | if valid { | |||
| 154 | + | return c.lockFocus | |||
| 155 | + | } | |||
| 156 | + | c.lockFocus, c.lockClicked = 0, false | |||
| 150 | for _, sc := range c.screens { | 157 | for _, sc := range c.screens { | ||
| 151 | if s, ok := c.lockSurface(sc); ok { | 158 | if s, ok := c.lockSurface(sc); ok { | ||
| 152 | c.lockFocus = s.ID | 159 | c.lockFocus = s.ID | ||
| @@ -273,7 +280,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 273 | } | 280 | } | ||
| 274 | if c.validLockID(id) { | 281 | if c.validLockID(id) { | ||
| 275 | if v.Pressed && c.lockFocus != id { | 282 | if v.Pressed && c.lockFocus != id { | ||
| 276 | - | c.lockFocus = id | 283 | + | c.lockFocus, c.lockClicked = id, true |
| 277 | if err := c.publishProtected(ctx); err != nil { | 284 | if err := c.publishProtected(ctx); err != nil { | ||
| 278 | return err | 285 | return err | ||
| 279 | } | 286 | } | ||
M internal/core/security_internal_test.go +32 -0
| @@ -72,6 +72,38 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 72 | 72 | } | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | + | // Lock clients map their surfaces one by one: the focused output's surface | |
| 76 | + | // takes keyboard focus when it maps after another one, unless the user | |
| 77 | + | // clicked a surface. | |
| 78 | + | func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) { | |
| 79 | + | c, _, _ := hiddenCaptureCommands(t) | |
| 80 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 81 | + | c.focusScreen = c.screenIndex("B") | |
| 82 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |
| 83 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 84 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 85 | + | c.ch.Security = gate | |
| 86 | + | c.syncSecurity() | |
| 87 | + | focusB := c.focusScreen | |
| 88 | + | ||
| 89 | + | a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200} | |
| 90 | + | b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300} | |
| 91 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}}) | |
| 92 | + | if got := c.lockKeyboardFocus(); got != 10 { | |
| 93 | + | t.Fatalf("only mapped surface not focused: %d", got) | |
| 94 | + | } | |
| 95 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | |
| 96 | + | if got := c.lockKeyboardFocus(); got != 11 { | |
| 97 | + | t.Fatalf("focused output surface not focused: %d", got) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | c.lockFocus, c.lockClicked = 10, true // as a click on A does | |
| 101 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | |
| 102 | + | if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB { | |
| 103 | + | t.Fatalf("clicked surface lost focus: %d", got) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 75 | 107 | func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | |
| 76 | 108 | c, _, commands := hiddenCaptureCommands(t) | |
| 77 | 109 | gate := portsmocks.NewMockSessionSecurity(t) | |
| @@ -72,6 +72,38 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||||
| 72 | } | 72 | } | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | + | // Lock clients map their surfaces one by one: the focused output's surface | |||
| 76 | + | // takes keyboard focus when it maps after another one, unless the user | |||
| 77 | + | // clicked a surface. | |||
| 78 | + | func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) { | |||
| 79 | + | c, _, _ := hiddenCaptureCommands(t) | |||
| 80 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |||
| 81 | + | c.focusScreen = c.screenIndex("B") | |||
| 82 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |||
| 83 | + | gate := portsmocks.NewMockSessionSecurity(t) | |||
| 84 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |||
| 85 | + | c.ch.Security = gate | |||
| 86 | + | c.syncSecurity() | |||
| 87 | + | focusB := c.focusScreen | |||
| 88 | + | ||||
| 89 | + | a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200} | |||
| 90 | + | b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300} | |||
| 91 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}}) | |||
| 92 | + | if got := c.lockKeyboardFocus(); got != 10 { | |||
| 93 | + | t.Fatalf("only mapped surface not focused: %d", got) | |||
| 94 | + | } | |||
| 95 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | |||
| 96 | + | if got := c.lockKeyboardFocus(); got != 11 { | |||
| 97 | + | t.Fatalf("focused output surface not focused: %d", got) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | c.lockFocus, c.lockClicked = 10, true // as a click on A does | |||
| 101 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | |||
| 102 | + | if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB { | |||
| 103 | + | t.Fatalf("clicked surface lost focus: %d", got) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 75 | func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | 107 | func TestWiredZeroEpochRejectsRawInputAndCommandKeepsOwnerEpoch(t *testing.T) { | ||
| 76 | c, _, commands := hiddenCaptureCommands(t) | 108 | c, _, commands := hiddenCaptureCommands(t) | ||
| 77 | gate := portsmocks.NewMockSessionSecurity(t) | 109 | gate := portsmocks.NewMockSessionSecurity(t) | ||