fix(core): pin lock focus on any click or key press
- commit
- 0b4c7808b4ea9644d9804af634a75c97d660c73f
- author
- brice · · 2026-10-02 10:05 UTC
- parent
- 34fd4a7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +61 -38
M internal/core/core.go +1 -1
| @@ -70,7 +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 | + | lockPinned bool // lockFocus was clicked or typed into: it keeps focus | |
| 74 | 74 | inputKeys map[string]bool | |
| 75 | 75 | inputActive bool | |
| 76 | 76 | startup [][]string | |
| @@ -70,7 +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 | + | lockPinned bool // lockFocus was clicked or typed into: it keeps focus |
| 74 | inputKeys map[string]bool | 74 | inputKeys map[string]bool | ||
| 75 | inputActive bool | 75 | inputActive bool | ||
| 76 | startup [][]string | 76 | startup [][]string | ||
M internal/core/security.go +16 -11
| @@ -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, c.lockClicked = nil, 0, false | |
| 24 | + | c.lockSurfaces, c.lockFocus, c.lockPinned = 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 | |
| @@ -136,24 +136,25 @@ func (c *Core) validLockID(id WindowID) bool { | |||
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | 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. | |
| 139 | + | // user is looking, then any other. A surface the user clicked or typed into is | |
| 140 | + | // pinned and keeps focus. Until then, focus moves to the focused output's | |
| 141 | + | // surface once it maps: lock clients map their surfaces one by one, in any | |
| 142 | + | // order. | |
| 142 | 143 | func (c *Core) lockKeyboardFocus() WindowID { | |
| 143 | 144 | valid := c.validLockID(c.lockFocus) | |
| 144 | - | if valid && c.lockClicked { | |
| 145 | + | if valid && c.lockPinned { | |
| 145 | 146 | return c.lockFocus | |
| 146 | 147 | } | |
| 147 | 148 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | |
| 148 | 149 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | |
| 149 | - | c.lockFocus, c.lockClicked = s.ID, false | |
| 150 | + | c.lockFocus, c.lockPinned = s.ID, false | |
| 150 | 151 | return c.lockFocus | |
| 151 | 152 | } | |
| 152 | 153 | } | |
| 153 | 154 | if valid { | |
| 154 | 155 | return c.lockFocus | |
| 155 | 156 | } | |
| 156 | - | c.lockFocus, c.lockClicked = 0, false | |
| 157 | + | c.lockFocus, c.lockPinned = 0, false | |
| 157 | 158 | for _, sc := range c.screens { | |
| 158 | 159 | if s, ok := c.lockSurface(sc); ok { | |
| 159 | 160 | c.lockFocus = s.ID | |
| @@ -246,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 246 | 247 | switch v := ev.(type) { | |
| 247 | 248 | case ports.KeyEvent: | |
| 248 | 249 | if id := c.lockKeyboardFocus(); id != 0 { | |
| 250 | + | c.lockPinned = true // typing must not move focus mid-secret | |
| 249 | 251 | return c.command(ctx, ports.ForwardKey{ID: id, Key: v}) | |
| 250 | 252 | } | |
| 251 | 253 | case ports.PointerMotion: | |
| @@ -279,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||
| 279 | 281 | delete(c.buttons, v.Button) | |
| 280 | 282 | } | |
| 281 | 283 | if c.validLockID(id) { | |
| 282 | - | if v.Pressed && c.lockFocus != id { | |
| 283 | - | c.lockFocus, c.lockClicked = id, true | |
| 284 | - | if err := c.publishProtected(ctx); err != nil { | |
| 285 | - | return err | |
| 284 | + | if v.Pressed { | |
| 285 | + | changed := c.lockFocus != id | |
| 286 | + | c.lockFocus, c.lockPinned = id, true | |
| 287 | + | if changed { | |
| 288 | + | if err := c.publishProtected(ctx); err != nil { | |
| 289 | + | return err | |
| 290 | + | } | |
| 286 | 291 | } | |
| 287 | 292 | } | |
| 288 | 293 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | |
| @@ -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, c.lockClicked = nil, 0, false | 24 | + | c.lockSurfaces, c.lockFocus, c.lockPinned = 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 | ||
| @@ -136,24 +136,25 @@ func (c *Core) validLockID(id WindowID) bool { | |||||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | // lockKeyboardFocus prefers the lock surface of the focused output, where the | 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. | 139 | + | // user is looking, then any other. A surface the user clicked or typed into is |
| 140 | - | // Without a click, focus moves to the focused output's surface once it maps: | 140 | + | // pinned and keeps focus. Until then, focus moves to the focused output's |
| 141 | - | // lock clients map their surfaces one by one, in any order. | 141 | + | // surface once it maps: lock clients map their surfaces one by one, in any |
| 142 | + | // order. | |||
| 142 | func (c *Core) lockKeyboardFocus() WindowID { | 143 | func (c *Core) lockKeyboardFocus() WindowID { | ||
| 143 | valid := c.validLockID(c.lockFocus) | 144 | valid := c.validLockID(c.lockFocus) | ||
| 144 | - | if valid && c.lockClicked { | 145 | + | if valid && c.lockPinned { |
| 145 | return c.lockFocus | 146 | return c.lockFocus | ||
| 146 | } | 147 | } | ||
| 147 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | 148 | if c.focusScreen >= 0 && c.focusScreen < len(c.screens) { | ||
| 148 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | 149 | if s, ok := c.lockSurface(c.screens[c.focusScreen]); ok { | ||
| 149 | - | c.lockFocus, c.lockClicked = s.ID, false | 150 | + | c.lockFocus, c.lockPinned = s.ID, false |
| 150 | return c.lockFocus | 151 | return c.lockFocus | ||
| 151 | } | 152 | } | ||
| 152 | } | 153 | } | ||
| 153 | if valid { | 154 | if valid { | ||
| 154 | return c.lockFocus | 155 | return c.lockFocus | ||
| 155 | } | 156 | } | ||
| 156 | - | c.lockFocus, c.lockClicked = 0, false | 157 | + | c.lockFocus, c.lockPinned = 0, false |
| 157 | for _, sc := range c.screens { | 158 | for _, sc := range c.screens { | ||
| 158 | if s, ok := c.lockSurface(sc); ok { | 159 | if s, ok := c.lockSurface(sc); ok { | ||
| 159 | c.lockFocus = s.ID | 160 | c.lockFocus = s.ID | ||
| @@ -246,6 +247,7 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 246 | switch v := ev.(type) { | 247 | switch v := ev.(type) { | ||
| 247 | case ports.KeyEvent: | 248 | case ports.KeyEvent: | ||
| 248 | if id := c.lockKeyboardFocus(); id != 0 { | 249 | if id := c.lockKeyboardFocus(); id != 0 { | ||
| 250 | + | c.lockPinned = true // typing must not move focus mid-secret | |||
| 249 | return c.command(ctx, ports.ForwardKey{ID: id, Key: v}) | 251 | return c.command(ctx, ports.ForwardKey{ID: id, Key: v}) | ||
| 250 | } | 252 | } | ||
| 251 | case ports.PointerMotion: | 253 | case ports.PointerMotion: | ||
| @@ -279,10 +281,13 @@ func (c *Core) protectedInput(ctx context.Context, ev ports.InputEvent) error { | |||||
| 279 | delete(c.buttons, v.Button) | 281 | delete(c.buttons, v.Button) | ||
| 280 | } | 282 | } | ||
| 281 | if c.validLockID(id) { | 283 | if c.validLockID(id) { | ||
| 282 | - | if v.Pressed && c.lockFocus != id { | 284 | + | if v.Pressed { |
| 283 | - | c.lockFocus, c.lockClicked = id, true | 285 | + | changed := c.lockFocus != id |
| 284 | - | if err := c.publishProtected(ctx); err != nil { | 286 | + | c.lockFocus, c.lockPinned = id, true |
| 285 | - | return err | 287 | + | if changed { |
| 288 | + | if err := c.publishProtected(ctx); err != nil { | |||
| 289 | + | return err | |||
| 290 | + | } | |||
| 286 | } | 291 | } | ||
| 287 | } | 292 | } | ||
| 288 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | 293 | if err := c.command(ctx, ports.PointerButtonTo{ID: id, Button: v.Button, Pressed: v.Pressed, TimeMsec: v.TimeMsec}); err != nil { | ||
M internal/core/security_internal_test.go +44 -26
| @@ -74,33 +74,51 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||
| 74 | 74 | ||
| 75 | 75 | // Lock clients map their surfaces one by one: the focused output's surface | |
| 76 | 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 | - | } | |
| 77 | + | // already clicked or typed into a surface. | |
| 78 | + | func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) { | |
| 79 | + | for _, tc := range []struct { | |
| 80 | + | name string | |
| 81 | + | input []ports.InputEvent // on A, before B maps | |
| 82 | + | want WindowID | |
| 83 | + | }{ | |
| 84 | + | {"no input", nil, 11}, | |
| 85 | + | {"click on focused A", []ports.InputEvent{ports.PointerMotion{X: 10, Y: 10}, ports.PointerButton{Button: 0x110, Pressed: true}}, 10}, | |
| 86 | + | {"typing", []ports.InputEvent{ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}}, 10}, | |
| 87 | + | } { | |
| 88 | + | t.Run(tc.name, func(t *testing.T) { | |
| 89 | + | c, _, _ := hiddenCaptureCommands(t) | |
| 90 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | |
| 91 | + | c.focusScreen = c.screenIndex("B") | |
| 92 | + | state := ports.SecurityState{Generation: 1, Protected: true} | |
| 93 | + | gate := portsmocks.NewMockSessionSecurity(t) | |
| 94 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | |
| 95 | + | c.ch.Security = gate | |
| 96 | + | c.syncSecurity() | |
| 97 | + | ctx := context.Background() | |
| 98 | + | mapped := func(s ...ports.LockSurfacePlacement) { | |
| 99 | + | t.Helper() | |
| 100 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: s}) | |
| 101 | + | if err := c.publishProtected(ctx); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | } | |
| 99 | 105 | ||
| 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) | |
| 106 | + | a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200} | |
| 107 | + | b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300} | |
| 108 | + | mapped(a) | |
| 109 | + | if c.keyboard.sent != 10 { | |
| 110 | + | t.Fatalf("only mapped surface not focused: %d", c.keyboard.sent) | |
| 111 | + | } | |
| 112 | + | for _, ev := range tc.input { | |
| 113 | + | if err := c.protectedInput(ctx, ev); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | mapped(a, b) | |
| 118 | + | if c.keyboard.sent != tc.want { | |
| 119 | + | t.Fatalf("keyboard focus %d, want %d", c.keyboard.sent, tc.want) | |
| 120 | + | } | |
| 121 | + | }) | |
| 104 | 122 | } | |
| 105 | 123 | } | |
| 106 | 124 | ||
| @@ -74,33 +74,51 @@ func TestProtectedDropsPrivateOffscreenCaptureAndDesktopEffects(t *testing.T) { | |||||
| 74 | 74 | ||||
| 75 | // Lock clients map their surfaces one by one: the focused output's surface | 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 | 76 | // takes keyboard focus when it maps after another one, unless the user | ||
| 77 | - | // clicked a surface. | 77 | + | // already clicked or typed into a surface. |
| 78 | - | func TestLockFocusMovesToFocusedOutputSurfaceUntilClicked(t *testing.T) { | 78 | + | func TestLockFocusMovesToFocusedOutputSurfaceUntilPinned(t *testing.T) { |
| 79 | - | c, _, _ := hiddenCaptureCommands(t) | 79 | + | for _, tc := range []struct { |
| 80 | - | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) | 80 | + | name string |
| 81 | - | c.focusScreen = c.screenIndex("B") | 81 | + | input []ports.InputEvent // on A, before B maps |
| 82 | - | state := ports.SecurityState{Generation: 1, Protected: true} | 82 | + | want WindowID |
| 83 | - | gate := portsmocks.NewMockSessionSecurity(t) | 83 | + | }{ |
| 84 | - | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) | 84 | + | {"no input", nil, 11}, |
| 85 | - | c.ch.Security = gate | 85 | + | {"click on focused A", []ports.InputEvent{ports.PointerMotion{X: 10, Y: 10}, ports.PointerButton{Button: 0x110, Pressed: true}}, 10}, |
| 86 | - | c.syncSecurity() | 86 | + | {"typing", []ports.InputEvent{ports.KeyEvent{Keysym: "a", Keycode: 30, Pressed: true}}, 10}, |
| 87 | - | focusB := c.focusScreen | 87 | + | } { |
| 88 | - | 88 | + | t.Run(tc.name, func(t *testing.T) { | |
| 89 | - | a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200} | 89 | + | c, _, _ := hiddenCaptureCommands(t) |
| 90 | - | b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300} | 90 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300}) |
| 91 | - | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a}}) | 91 | + | c.focusScreen = c.screenIndex("B") |
| 92 | - | if got := c.lockKeyboardFocus(); got != 10 { | 92 | + | state := ports.SecurityState{Generation: 1, Protected: true} |
| 93 | - | t.Fatalf("only mapped surface not focused: %d", got) | 93 | + | gate := portsmocks.NewMockSessionSecurity(t) |
| 94 | - | } | 94 | + | gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }) |
| 95 | - | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | 95 | + | c.ch.Security = gate |
| 96 | - | if got := c.lockKeyboardFocus(); got != 11 { | 96 | + | c.syncSecurity() |
| 97 | - | t.Fatalf("focused output surface not focused: %d", got) | 97 | + | ctx := context.Background() |
| 98 | - | } | 98 | + | mapped := func(s ...ports.LockSurfacePlacement) { |
| 99 | + | t.Helper() | |||
| 100 | + | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: s}) | |||
| 101 | + | if err := c.publishProtected(ctx); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 99 | 105 | ||||
| 100 | - | c.lockFocus, c.lockClicked = 10, true // as a click on A does | 106 | + | a := ports.LockSurfacePlacement{ID: 10, Output: "A", Width: 300, Height: 200} |
| 101 | - | c.applyLockChanged(ports.SessionLockChanged{State: state, Surfaces: []ports.LockSurfacePlacement{a, b}}) | 107 | + | b := ports.LockSurfacePlacement{ID: 11, Output: "B", Width: 400, Height: 300} |
| 102 | - | if got := c.lockKeyboardFocus(); got != 10 || c.focusScreen != focusB { | 108 | + | mapped(a) |
| 103 | - | t.Fatalf("clicked surface lost focus: %d", got) | 109 | + | if c.keyboard.sent != 10 { |
| 110 | + | t.Fatalf("only mapped surface not focused: %d", c.keyboard.sent) | |||
| 111 | + | } | |||
| 112 | + | for _, ev := range tc.input { | |||
| 113 | + | if err := c.protectedInput(ctx, ev); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | mapped(a, b) | |||
| 118 | + | if c.keyboard.sent != tc.want { | |||
| 119 | + | t.Fatalf("keyboard focus %d, want %d", c.keyboard.sent, tc.want) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 104 | } | 122 | } | ||
| 105 | } | 123 | } | ||
| 106 | 124 | ||||