detached c0db34c

feat(lock): show the lock prompt on the configured lock.output (#1)

## Problem
The lock prompt showed on the output with the lowest wl_output global. On a multi-monitor setup that can be a screen showing another input source, so the prompt was invisible.

## Change
- New config key `lock.output = <output name>` (e.g. `DP-2`): the prompt shows there whenever that output is covered. Applies from the next lock.
- Without it, or when that output is absent, the prompt follows the lock surface with keyboard focus.
- If the host output goes away: preferred output, then the focused screen, then the lowest output.
- Daemon→visual pipe: Lock frame carries the output name as a 4th field; wire magic bumped to `NAV2` so a version mismatch fails explicitly. Name bounded to 64 bytes (neferclient's wl_output name limit), no control characters.

## Checks
- `make check`, `make race`, `make mocks-check`
- Headless lock and fade tests pass against NeferWL `fix/lock-focus-output`.

## Note
Restart the daemon after upgrading: a running old daemon cannot drive a new visual (`NAV1` vs `NAV2`).

Reviewed-on: https://git.bnema.dev/bnema/neferafk/pulls/1
commit
c0db34c0c2a11ce672fe2a381cf0ecabd5aa7bee
author
brice · · 2026-10-02 10:31 UTC
committer
bnema · · 2026-10-02 10:31 UTC
parent
a514042
signature
unsigned
refs
tag: v0.4.0
14 files · +196 -43
M README.md +3 -0
@@ -71,6 +71,7 @@ auth.mode = password
7171 | `fade.after` | duration or `off` | `6m` |
7272 | `fade.duration` | duration | `2s` |
7373 | `lock.after` | duration or `off` | `5m` |
74+| `lock.output` | output name (e.g. `DP-2`) | none |
7475 | `screens.off-after` | duration or `off` | `7m` |
7576 | `sleep.after` | duration or `off` | `20m` |
7677 | `auth.mode` | `password` or `pin` | `password` |
@@ -80,6 +81,8 @@ auth.mode = password
8081
8182 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.
8283
84+The lock prompt shows on `lock.output` when that output is connected. Otherwise it follows the lock surface that has keyboard focus, which the compositor chooses. The other outputs show black. A change applies from the next lock. `wlr-randr` lists the output names.
85+
8386 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.
8487
8588 With the defaults, the lock comes first and the fade then darkens the lock screen before the screens go off. Set `fade.after` below `lock.after` to fade before locking.
@@ -71,6 +71,7 @@ auth.mode = password
71 | `fade.after` | duration or `off` | `6m` |71 | `fade.after` | duration or `off` | `6m` |
72 | `fade.duration` | duration | `2s` |72 | `fade.duration` | duration | `2s` |
73 | `lock.after` | duration or `off` | `5m` |73 | `lock.after` | duration or `off` | `5m` |
74+| `lock.output` | output name (e.g. `DP-2`) | none |
74 | `screens.off-after` | duration or `off` | `7m` |75 | `screens.off-after` | duration or `off` | `7m` |
75 | `sleep.after` | duration or `off` | `20m` |76 | `sleep.after` | duration or `off` | `20m` |
76 | `auth.mode` | `password` or `pin` | `password` |77 | `auth.mode` | `password` or `pin` | `password` |
@@ -80,6 +81,8 @@ auth.mode = password
80 81
81 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.82 Durations use Go syntax (`30s`, `5m`, `1h`), up to 24h. The order of the actions comes from their delays: for example, `lock.after = 20s` with `screens.off-after = 50s` turns the screens off 30 seconds after the lock.
82 83
84+The lock prompt shows on `lock.output` when that output is connected. Otherwise it follows the lock surface that has keyboard focus, which the compositor chooses. The other outputs show black. A change applies from the next lock. `wlr-randr` lists the output names.
85+
83 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.86 PIN mode compares a 6 to 32 digit PIN read from the daemon's environment or from `pass show ENTRY`. If the PIN source is unavailable or invalid, the lock asks for the password through PAM.
84 87
85 With the defaults, the lock comes first and the fade then darkens the lock screen before the screens go off. Set `fade.after` below `lock.after` to fade before locking.88 With the defaults, the lock comes first and the fade then darkens the lock screen before the screens go off. Set `fade.after` below `lock.after` to fade before locking.
M examples/config +4 -0
@@ -9,6 +9,10 @@ lock.after = 5m
99 screens.off-after = 7m
1010 sleep.after = 20m
1111
12+# Output that shows the lock prompt (names from `wlr-randr`). Without it, the
13+# prompt follows keyboard focus.
14+# lock.output = DP-2
15+
1216 # password (PAM service /etc/pam.d/neferafk) or pin.
1317 auth.mode = password
1418 # PIN mode: 6 to 32 digits from `pass show ENTRY` (or auth.pin-source = env
@@ -9,6 +9,10 @@ lock.after = 5m
9 screens.off-after = 7m9 screens.off-after = 7m
10 sleep.after = 20m10 sleep.after = 20m
11 11
12+# Output that shows the lock prompt (names from `wlr-randr`). Without it, the
13+# prompt follows keyboard focus.
14+# lock.output = DP-2
15+
12 # password (PAM service /etc/pam.d/neferafk) or pin.16 # password (PAM service /etc/pam.d/neferafk) or pin.
13 auth.mode = password17 auth.mode = password
14 # PIN mode: 6 to 32 digits from `pass show ENTRY` (or auth.pin-source = env18 # PIN mode: 6 to 32 digits from `pass show ENTRY` (or auth.pin-source = env
M internal/adapters/config/config.go +2 -0
@@ -80,6 +80,8 @@ func Parse(r io.Reader) (ports.Config, error) {
8080 target = &cfg.OffAfter
8181 case "sleep.after":
8282 target = &cfg.SleepAfter
83+ case "lock.output":
84+ cfg.LockOutput = value
8385 case "auth.mode":
8486 cfg.Auth.Mode = ports.AuthMode(value)
8587 case "auth.pin-source":
@@ -80,6 +80,8 @@ func Parse(r io.Reader) (ports.Config, error) {
80 target = &cfg.OffAfter80 target = &cfg.OffAfter
81 case "sleep.after":81 case "sleep.after":
82 target = &cfg.SleepAfter82 target = &cfg.SleepAfter
83+ case "lock.output":
84+ cfg.LockOutput = value
83 case "auth.mode":85 case "auth.mode":
84 cfg.Auth.Mode = ports.AuthMode(value)86 cfg.Auth.Mode = ports.AuthMode(value)
85 case "auth.pin-source":87 case "auth.pin-source":
M internal/adapters/config/config_test.go +14 -0
@@ -25,6 +25,20 @@ func TestRejectTransactionalBoundsUnknownDuplicateAndSecrets(t *testing.T) {
2525 }
2626 }
2727 }
28+func TestLockOutput(t *testing.T) {
29+ c, err := Parse(strings.NewReader("lock.output = DP-2\n"))
30+ if err != nil || c.LockOutput != "DP-2" {
31+ t.Fatal(c, err)
32+ }
33+ if _, err := Parse(strings.NewReader("lock.output = " + strings.Repeat("x", ports.MaxOutputName+1))); err == nil {
34+ t.Fatal("oversized output name accepted")
35+ }
36+ cfg := ports.Defaults()
37+ cfg.LockOutput = "DP\x01"
38+ if err := cfg.Validate(); err == nil {
39+ t.Fatal("control character in output name accepted")
40+ }
41+}
2842 func TestPassReferenceMetadataOnly(t *testing.T) {
2943 c, err := Parse(strings.NewReader("auth.pin-source=pass\nauth.pin-entry=desktop/neferafk\n"))
3044 if err != nil || c.Auth.PINEntry != "desktop/neferafk" {
@@ -25,6 +25,20 @@ func TestRejectTransactionalBoundsUnknownDuplicateAndSecrets(t *testing.T) {
25 }25 }
26 }26 }
27 }27 }
28+func TestLockOutput(t *testing.T) {
29+ c, err := Parse(strings.NewReader("lock.output = DP-2\n"))
30+ if err != nil || c.LockOutput != "DP-2" {
31+ t.Fatal(c, err)
32+ }
33+ if _, err := Parse(strings.NewReader("lock.output = " + strings.Repeat("x", ports.MaxOutputName+1))); err == nil {
34+ t.Fatal("oversized output name accepted")
35+ }
36+ cfg := ports.Defaults()
37+ cfg.LockOutput = "DP\x01"
38+ if err := cfg.Validate(); err == nil {
39+ t.Fatal("control character in output name accepted")
40+ }
41+}
28 func TestPassReferenceMetadataOnly(t *testing.T) {42 func TestPassReferenceMetadataOnly(t *testing.T) {
29 c, err := Parse(strings.NewReader("auth.pin-source=pass\nauth.pin-entry=desktop/neferafk\n"))43 c, err := Parse(strings.NewReader("auth.pin-source=pass\nauth.pin-entry=desktop/neferafk\n"))
30 if err != nil || c.Auth.PINEntry != "desktop/neferafk" {44 if err != nil || c.Auth.PINEntry != "desktop/neferafk" {
M internal/adapters/visual/guilock.go +33 -9
@@ -36,10 +36,12 @@ type lockState struct {
3636 // lockConfig wires the session lock to its caller. Every callback runs on the
3737 // lock's owner goroutine.
3838 type lockConfig struct {
39- // View builds the content of the output that hosts it: the one whose
40- // surface has keyboard focus, initially the lowest output. Other outputs
41- // show plain black.
39+ // View builds the content of the output that hosts it: Output while it
40+ // is covered, otherwise the one whose surface has keyboard focus,
41+ // initially the lowest output. Other outputs show plain black.
4242 View func(*nefergui.Frame, lockState)
43+ // Output is the preferred host output name; empty for none.
44+ Output string
4345 // OnLocked runs once when the compositor confirms the lock.
4446 OnLocked func()
4547 // OnOutputError reports an output added after the lock was acquired that
@@ -145,6 +147,7 @@ type locker struct {
145147 seat *neferclient.Seat
146148 secret *neferclient.SecretBuffer
147149 host *screen
150+ focus *screen // has keyboard focus; may differ from host on the preferred output
148151 initial map[uint32]bool // outputs present at acquisition: their failures abort
149152 status lockStatus
150153 unlock bool // requested; honoured once locked
@@ -188,12 +191,19 @@ func (l *locker) cover(o neferclient.Output) error {
188191 s := newScreen(l.conn, surf, o, false)
189192 s.view = func(f *nefergui.Frame) { l.view(f, s) }
190193 l.add(s)
191- if l.host == nil { // outputs are covered in ascending order: the lowest hosts
194+ // Outputs are covered in ascending order: the lowest hosts until the
195+ // preferred one appears.
196+ if l.host == nil || l.preferred(s) {
192197 l.setHost(s)
193198 }
194199 return nil
195200 }
196201
202+// preferred reports whether s is on the configured host output.
203+func (l *locker) preferred(s *screen) bool {
204+ return l.cfg.Output != "" && s.name == l.cfg.Output
205+}
206+
197207 // view shows the lock content on the host output and plain black elsewhere.
198208 func (l *locker) view(f *nefergui.Frame, s *screen) {
199209 if s != l.host {
@@ -249,14 +259,21 @@ func (l *locker) errorPolicy(err error) error {
249259 return nil
250260 }
251261
252-// forgetHost picks a new host when the host screen goes away.
262+// forgetHost picks a new host when the host screen goes away: the preferred
263+// output, else the focused screen, else the lowest output.
253264 func (l *locker) forgetHost() {
254- if l.host == nil || l.byID[l.host.surf.ID()] == l.host {
265+ if !l.has(l.focus) {
266+ l.focus = nil
267+ }
268+ if l.host == nil || l.has(l.host) {
255269 return
256270 }
257- l.host = nil
271+ l.host = l.focus
258272 for _, s := range l.byID {
259- if l.host == nil || s.output < l.host.output {
273+ switch {
274+ case l.preferred(s):
275+ l.host = s
276+ case l.host == nil || l.host != l.focus && !l.preferred(l.host) && s.output < l.host.output:
260277 l.host = s
261278 }
262279 }
@@ -308,7 +325,14 @@ func (l *locker) LockFinished() {
308325 }
309326
310327 func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
311- if s := l.byID[id]; s != nil && focused {
328+ // The secret buffer belongs to the seat, so typing works whichever
329+ // surface has focus: the preferred output keeps the prompt.
330+ s := l.byID[id]
331+ if s == nil || !focused {
332+ return
333+ }
334+ l.focus = s
335+ if l.host == nil || !l.preferred(l.host) {
312336 l.setHost(s)
313337 }
314338 }
@@ -36,10 +36,12 @@ type lockState struct {
36 // lockConfig wires the session lock to its caller. Every callback runs on the36 // lockConfig wires the session lock to its caller. Every callback runs on the
37 // lock's owner goroutine.37 // lock's owner goroutine.
38 type lockConfig struct {38 type lockConfig struct {
39- // View builds the content of the output that hosts it: the one whose39+ // View builds the content of the output that hosts it: Output while it
40- // surface has keyboard focus, initially the lowest output. Other outputs40+ // is covered, otherwise the one whose surface has keyboard focus,
41- // show plain black.41+ // initially the lowest output. Other outputs show plain black.
42 View func(*nefergui.Frame, lockState)42 View func(*nefergui.Frame, lockState)
43+ // Output is the preferred host output name; empty for none.
44+ Output string
43 // OnLocked runs once when the compositor confirms the lock.45 // OnLocked runs once when the compositor confirms the lock.
44 OnLocked func()46 OnLocked func()
45 // OnOutputError reports an output added after the lock was acquired that47 // OnOutputError reports an output added after the lock was acquired that
@@ -145,6 +147,7 @@ type locker struct {
145 seat *neferclient.Seat147 seat *neferclient.Seat
146 secret *neferclient.SecretBuffer148 secret *neferclient.SecretBuffer
147 host *screen149 host *screen
150+ focus *screen // has keyboard focus; may differ from host on the preferred output
148 initial map[uint32]bool // outputs present at acquisition: their failures abort151 initial map[uint32]bool // outputs present at acquisition: their failures abort
149 status lockStatus152 status lockStatus
150 unlock bool // requested; honoured once locked153 unlock bool // requested; honoured once locked
@@ -188,12 +191,19 @@ func (l *locker) cover(o neferclient.Output) error {
188 s := newScreen(l.conn, surf, o, false)191 s := newScreen(l.conn, surf, o, false)
189 s.view = func(f *nefergui.Frame) { l.view(f, s) }192 s.view = func(f *nefergui.Frame) { l.view(f, s) }
190 l.add(s)193 l.add(s)
191- if l.host == nil { // outputs are covered in ascending order: the lowest hosts194+ // Outputs are covered in ascending order: the lowest hosts until the
195+ // preferred one appears.
196+ if l.host == nil || l.preferred(s) {
192 l.setHost(s)197 l.setHost(s)
193 }198 }
194 return nil199 return nil
195 }200 }
196 201
202+// preferred reports whether s is on the configured host output.
203+func (l *locker) preferred(s *screen) bool {
204+ return l.cfg.Output != "" && s.name == l.cfg.Output
205+}
206+
197 // view shows the lock content on the host output and plain black elsewhere.207 // view shows the lock content on the host output and plain black elsewhere.
198 func (l *locker) view(f *nefergui.Frame, s *screen) {208 func (l *locker) view(f *nefergui.Frame, s *screen) {
199 if s != l.host {209 if s != l.host {
@@ -249,14 +259,21 @@ func (l *locker) errorPolicy(err error) error {
249 return nil259 return nil
250 }260 }
251 261
252-// forgetHost picks a new host when the host screen goes away.262+// forgetHost picks a new host when the host screen goes away: the preferred
263+// output, else the focused screen, else the lowest output.
253 func (l *locker) forgetHost() {264 func (l *locker) forgetHost() {
254- if l.host == nil || l.byID[l.host.surf.ID()] == l.host {265+ if !l.has(l.focus) {
266+ l.focus = nil
267+ }
268+ if l.host == nil || l.has(l.host) {
255 return269 return
256 }270 }
257- l.host = nil271+ l.host = l.focus
258 for _, s := range l.byID {272 for _, s := range l.byID {
259- if l.host == nil || s.output < l.host.output {273+ switch {
274+ case l.preferred(s):
275+ l.host = s
276+ case l.host == nil || l.host != l.focus && !l.preferred(l.host) && s.output < l.host.output:
260 l.host = s277 l.host = s
261 }278 }
262 }279 }
@@ -308,7 +325,14 @@ func (l *locker) LockFinished() {
308 }325 }
309 326
310 func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {327 func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
311- if s := l.byID[id]; s != nil && focused {328+ // The secret buffer belongs to the seat, so typing works whichever
329+ // surface has focus: the preferred output keeps the prompt.
330+ s := l.byID[id]
331+ if s == nil || !focused {
332+ return
333+ }
334+ l.focus = s
335+ if l.host == nil || !l.preferred(l.host) {
312 l.setHost(s)336 l.setHost(s)
313 }337 }
314 }338 }
A internal/adapters/visual/guilock_test.go +62 -0
@@ -0,0 +1,62 @@
1+package visual
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferclient"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// hostLocker builds a locker over screens keyed by surface ID. Only host
11+// selection runs here: neither drawing nor the connection is used.
12+func hostLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {
13+ return &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}
14+}
15+
16+func TestKeyboardFocusMovesHostOnlyWithoutPreferredOutput(t *testing.T) {
17+ hdmi := &screen{output: 49, name: "HDMI-A-1"}
18+ dp := &screen{output: 50, name: "DP-2"}
19+
20+ l := hostLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
21+ l.setHost(hdmi)
22+ l.KeyboardFocus(2, true)
23+ require.Same(t, dp, l.host, "without lock.output the prompt follows keyboard focus")
24+
25+ l = hostLocker("DP-2", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
26+ l.setHost(dp)
27+ l.KeyboardFocus(1, true)
28+ require.Same(t, dp, l.host, "the preferred output keeps the prompt")
29+
30+ l = hostLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
31+ l.setHost(hdmi)
32+ l.KeyboardFocus(2, true)
33+ require.Same(t, dp, l.host, "an absent preferred output falls back to keyboard focus")
34+}
35+
36+// forgetHost runs after a screen left byID: removed is the old host.
37+func TestForgetHostPrefersConfiguredOutputThenFocusThenLowest(t *testing.T) {
38+ removed := func() *screen { return &screen{output: 10, name: "GONE-1"} }
39+ hdmi := &screen{output: 49, name: "HDMI-A-1"}
40+ dp := &screen{output: 50, name: "DP-2"}
41+ usb := &screen{output: 51, name: "DP-3"}
42+ all := func() map[neferclient.SurfaceID]*screen {
43+ return map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp, 3: usb}
44+ }
45+
46+ l := hostLocker("DP-2", all())
47+ l.host = removed()
48+ l.focus = usb
49+ l.forgetHost()
50+ require.Same(t, dp, l.host, "the preferred output first, even over focus")
51+
52+ l = hostLocker("", all())
53+ l.host = removed()
54+ l.focus = usb
55+ l.forgetHost()
56+ require.Same(t, usb, l.host, "then the focused screen")
57+
58+ l = hostLocker("", all())
59+ l.host = removed()
60+ l.forgetHost()
61+ require.Same(t, hdmi, l.host, "then the lowest output")
62+}
@@ -0,0 +1,62 @@
1+package visual
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/neferclient"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// hostLocker builds a locker over screens keyed by surface ID. Only host
11+// selection runs here: neither drawing nor the connection is used.
12+func hostLocker(output string, byID map[neferclient.SurfaceID]*screen) *locker {
13+ return &locker{cfg: lockConfig{Output: output}, screens: &screens{byID: byID}}
14+}
15+
16+func TestKeyboardFocusMovesHostOnlyWithoutPreferredOutput(t *testing.T) {
17+ hdmi := &screen{output: 49, name: "HDMI-A-1"}
18+ dp := &screen{output: 50, name: "DP-2"}
19+
20+ l := hostLocker("", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
21+ l.setHost(hdmi)
22+ l.KeyboardFocus(2, true)
23+ require.Same(t, dp, l.host, "without lock.output the prompt follows keyboard focus")
24+
25+ l = hostLocker("DP-2", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
26+ l.setHost(dp)
27+ l.KeyboardFocus(1, true)
28+ require.Same(t, dp, l.host, "the preferred output keeps the prompt")
29+
30+ l = hostLocker("DP-9", map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp})
31+ l.setHost(hdmi)
32+ l.KeyboardFocus(2, true)
33+ require.Same(t, dp, l.host, "an absent preferred output falls back to keyboard focus")
34+}
35+
36+// forgetHost runs after a screen left byID: removed is the old host.
37+func TestForgetHostPrefersConfiguredOutputThenFocusThenLowest(t *testing.T) {
38+ removed := func() *screen { return &screen{output: 10, name: "GONE-1"} }
39+ hdmi := &screen{output: 49, name: "HDMI-A-1"}
40+ dp := &screen{output: 50, name: "DP-2"}
41+ usb := &screen{output: 51, name: "DP-3"}
42+ all := func() map[neferclient.SurfaceID]*screen {
43+ return map[neferclient.SurfaceID]*screen{1: hdmi, 2: dp, 3: usb}
44+ }
45+
46+ l := hostLocker("DP-2", all())
47+ l.host = removed()
48+ l.focus = usb
49+ l.forgetHost()
50+ require.Same(t, dp, l.host, "the preferred output first, even over focus")
51+
52+ l = hostLocker("", all())
53+ l.host = removed()
54+ l.focus = usb
55+ l.forgetHost()
56+ require.Same(t, usb, l.host, "then the focused screen")
57+
58+ l = hostLocker("", all())
59+ l.host = removed()
60+ l.forgetHost()
61+ require.Same(t, hdmi, l.host, "then the lowest output")
62+}
M internal/adapters/visual/lock.go +3 -1
@@ -220,7 +220,9 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
220220 }
221221 go func() { defer close(l.authDone); ctl.run(lctx) }()
222222 go clockWake(lctx.Done(), ctl.wake)
223- go func() { l.done <- s.locker.RunLock(lctx, newLockConfig(s.log, ctl, l.locked)) }()
223+ lcfg := newLockConfig(s.log, ctl, l.locked)
224+ lcfg.Output = c.Output
225+ go func() { l.done <- s.locker.RunLock(lctx, lcfg) }()
224226 return l
225227 }
226228
@@ -220,7 +220,9 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
220 }220 }
221 go func() { defer close(l.authDone); ctl.run(lctx) }()221 go func() { defer close(l.authDone); ctl.run(lctx) }()
222 go clockWake(lctx.Done(), ctl.wake)222 go clockWake(lctx.Done(), ctl.wake)
223- go func() { l.done <- s.locker.RunLock(lctx, newLockConfig(s.log, ctl, l.locked)) }()223+ lcfg := newLockConfig(s.log, ctl, l.locked)
224+ lcfg.Output = c.Output
225+ go func() { l.done <- s.locker.RunLock(lctx, lcfg) }()
224 return l226 return l
225 }227 }
226 228
M internal/adapters/visual/screen.go +10 -0
@@ -333,6 +333,16 @@ func (ss *screens) OutputRemoved(global uint32) {
333333 }
334334 }
335335
336+// has reports whether s is still one of the screens.
337+func (ss *screens) has(s *screen) bool {
338+ for _, t := range ss.byID {
339+ if t == s {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
336346 // covers reports whether an output already has a screen.
337347 func (ss *screens) covers(global uint32) bool {
338348 for _, s := range ss.byID {
@@ -333,6 +333,16 @@ func (ss *screens) OutputRemoved(global uint32) {
333 }333 }
334 }334 }
335 335
336+// has reports whether s is still one of the screens.
337+func (ss *screens) has(s *screen) bool {
338+ for _, t := range ss.byID {
339+ if t == s {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
336 // covers reports whether an output already has a screen.346 // covers reports whether an output already has a screen.
337 func (ss *screens) covers(global uint32) bool {347 func (ss *screens) covers(global uint32) bool {
338 for _, s := range ss.byID {348 for _, s := range ss.byID {
M internal/adapters/visualproc/wire.go +16 -11
@@ -4,11 +4,14 @@
44 //
55 // Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon):
66 //
7-// "NAV1" | kind u8 | length u32 BE | payload
7+// "NAV2" | kind u8 | length u32 BE | payload
8+//
9+// The magic names the format version: the daemon re-executes its binary as
10+// the visual, so an upgrade under a running daemon fails with ErrProtocol.
811 //
912 // Commands: 1 Fade {black u8, duration i64 ns BE}; 2 Lock {generation u64,
10-// then three u32-length-prefixed byte strings: PINSource, PINReference,
11-// EnvPIN}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased
13+// then four u32-length-prefixed byte strings: PINSource, PINReference,
14+// EnvPIN, Output}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased
1215 // {generation u64}. Process exit is observed by the launcher, not sent.
1316 package visualproc
1417
@@ -28,13 +31,15 @@ const (
2831 kindLock
2932 kindConfirmed
3033 kindReleased
31- maxPayload = 8 + 3*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret
34+ maxPayload = 8 + 4*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret + ports.MaxOutputName
3235 )
3336
37+const magic = "NAV2"
38+
3439 func writeFrame(w io.Writer, kind byte, payload []byte) error {
3540 buf := make([]byte, 9+len(payload))
3641 defer clear(buf)
37- copy(buf, "NAV1")
42+ copy(buf, magic)
3843 buf[4] = kind
3944 binary.BigEndian.PutUint32(buf[5:], uint32(len(payload)))
4045 copy(buf[9:], payload)
@@ -54,7 +59,7 @@ func readFrame(r io.Reader) (byte, []byte, error) {
5459 return 0, nil, err
5560 }
5661 n := binary.BigEndian.Uint32(h[5:])
57- if string(h[:4]) != "NAV1" || n > maxPayload {
62+ if string(h[:4]) != magic || n > maxPayload {
5863 return 0, nil, ErrProtocol
5964 }
6065 p := make([]byte, n)
@@ -78,8 +83,8 @@ func WriteCommand(w io.Writer, cmd ports.VisualCommand) error {
7883 return writeFrame(w, kindFade, p)
7984 case ports.VisualLock:
8085 a := c.Auth
81- fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN}
82- if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 {
86+ fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN, []byte(c.Output)}
87+ if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 || len(fields[3]) > ports.MaxOutputName {
8388 return ErrProtocol
8489 }
8590 p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation))
@@ -117,7 +122,7 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
117122 }
118123 gen := binary.BigEndian.Uint64(p)
119124 rest := p[8:]
120- var f [3][]byte
125+ var f [4][]byte
121126 for i := range f {
122127 if len(rest) < 4 {
123128 return nil, ErrProtocol
@@ -129,10 +134,10 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
129134 f[i] = rest[4 : 4+n]
130135 rest = rest[4+n:]
131136 }
132- if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret {
137+ if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret || len(f[3]) > ports.MaxOutputName {
133138 return nil, ErrProtocol
134139 }
135- return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}}, nil
140+ return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}, Output: string(f[3])}, nil
136141 }
137142 return nil, ErrProtocol
138143 }
@@ -4,11 +4,14 @@
4 //4 //
5 // Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon):5 // Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon):
6 //6 //
7-// "NAV1" | kind u8 | length u32 BE | payload7+// "NAV2" | kind u8 | length u32 BE | payload
8+//
9+// The magic names the format version: the daemon re-executes its binary as
10+// the visual, so an upgrade under a running daemon fails with ErrProtocol.
8 //11 //
9 // Commands: 1 Fade {black u8, duration i64 ns BE}; 2 Lock {generation u64,12 // Commands: 1 Fade {black u8, duration i64 ns BE}; 2 Lock {generation u64,
10-// then three u32-length-prefixed byte strings: PINSource, PINReference,13+// then four u32-length-prefixed byte strings: PINSource, PINReference,
11-// EnvPIN}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased14+// EnvPIN, Output}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased
12 // {generation u64}. Process exit is observed by the launcher, not sent.15 // {generation u64}. Process exit is observed by the launcher, not sent.
13 package visualproc16 package visualproc
14 17
@@ -28,13 +31,15 @@ const (
28 kindLock31 kindLock
29 kindConfirmed32 kindConfirmed
30 kindReleased33 kindReleased
31- maxPayload = 8 + 3*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret34+ maxPayload = 8 + 4*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret + ports.MaxOutputName
32 )35 )
33 36
37+const magic = "NAV2"
38+
34 func writeFrame(w io.Writer, kind byte, payload []byte) error {39 func writeFrame(w io.Writer, kind byte, payload []byte) error {
35 buf := make([]byte, 9+len(payload))40 buf := make([]byte, 9+len(payload))
36 defer clear(buf)41 defer clear(buf)
37- copy(buf, "NAV1")42+ copy(buf, magic)
38 buf[4] = kind43 buf[4] = kind
39 binary.BigEndian.PutUint32(buf[5:], uint32(len(payload)))44 binary.BigEndian.PutUint32(buf[5:], uint32(len(payload)))
40 copy(buf[9:], payload)45 copy(buf[9:], payload)
@@ -54,7 +59,7 @@ func readFrame(r io.Reader) (byte, []byte, error) {
54 return 0, nil, err59 return 0, nil, err
55 }60 }
56 n := binary.BigEndian.Uint32(h[5:])61 n := binary.BigEndian.Uint32(h[5:])
57- if string(h[:4]) != "NAV1" || n > maxPayload {62+ if string(h[:4]) != magic || n > maxPayload {
58 return 0, nil, ErrProtocol63 return 0, nil, ErrProtocol
59 }64 }
60 p := make([]byte, n)65 p := make([]byte, n)
@@ -78,8 +83,8 @@ func WriteCommand(w io.Writer, cmd ports.VisualCommand) error {
78 return writeFrame(w, kindFade, p)83 return writeFrame(w, kindFade, p)
79 case ports.VisualLock:84 case ports.VisualLock:
80 a := c.Auth85 a := c.Auth
81- fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN}86+ fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN, []byte(c.Output)}
82- if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 {87+ if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 || len(fields[3]) > ports.MaxOutputName {
83 return ErrProtocol88 return ErrProtocol
84 }89 }
85 p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation))90 p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation))
@@ -117,7 +122,7 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
117 }122 }
118 gen := binary.BigEndian.Uint64(p)123 gen := binary.BigEndian.Uint64(p)
119 rest := p[8:]124 rest := p[8:]
120- var f [3][]byte125+ var f [4][]byte
121 for i := range f {126 for i := range f {
122 if len(rest) < 4 {127 if len(rest) < 4 {
123 return nil, ErrProtocol128 return nil, ErrProtocol
@@ -129,10 +134,10 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
129 f[i] = rest[4 : 4+n]134 f[i] = rest[4 : 4+n]
130 rest = rest[4+n:]135 rest = rest[4+n:]
131 }136 }
132- if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret {137+ if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret || len(f[3]) > ports.MaxOutputName {
133 return nil, ErrProtocol138 return nil, ErrProtocol
134 }139 }
135- return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}}, nil140+ return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}, Output: string(f[3])}, nil
136 }141 }
137 return nil, ErrProtocol142 return nil, ErrProtocol
138 }143 }
M internal/adapters/visualproc/wire_test.go +29 -18
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "encoding/binary"
66 "reflect"
7+ "strings"
78 "testing"
89 "time"
910
@@ -12,7 +13,7 @@ import (
1213
1314 func TestCommandRoundTrip(t *testing.T) {
1415 var b bytes.Buffer
15- lock := ports.VisualLock{Generation: 4, Auth: ports.AuthBootstrap{Generation: 4, PINSource: ports.PINSourceEnv, PINReference: "r", EnvPIN: []byte("123456")}}
16+ lock := ports.VisualLock{Generation: 4, Auth: ports.AuthBootstrap{Generation: 4, PINSource: ports.PINSourceEnv, PINReference: "r", EnvPIN: []byte("123456")}, Output: "DP-2"}
1617 for _, c := range []ports.VisualCommand{ports.VisualFade{Black: true, Duration: 2 * time.Second}, ports.VisualFade{}, lock} {
1718 if err := WriteCommand(&b, c); err != nil {
1819 t.Fatal(err)
@@ -26,7 +27,7 @@ func TestCommandRoundTrip(t *testing.T) {
2627 }
2728 c, err := ReadCommand(&b)
2829 got, ok := c.(ports.VisualLock)
29- if err != nil || !ok || got.Generation != 4 || got.Auth.Generation != 4 || got.Auth.PINSource != ports.PINSourceEnv || got.Auth.PINReference != "r" || string(got.Auth.EnvPIN) != "123456" {
30+ if err != nil || !ok || got.Generation != 4 || got.Auth.Generation != 4 || got.Auth.PINSource != ports.PINSourceEnv || got.Auth.PINReference != "r" || string(got.Auth.EnvPIN) != "123456" || got.Output != "DP-2" {
3031 t.Fatalf("%#v %v", c, err)
3132 }
3233 }
@@ -39,7 +40,10 @@ func TestCommandRejectsMismatchedGenerationAndGarbage(t *testing.T) {
3940 if err := WriteCommand(&b, ports.VisualLock{}); err == nil {
4041 t.Fatal("zero generation encoded")
4142 }
42- for _, in := range []string{"", "NAV1", "XXXX\x01\x00\x00\x00\x00", "NAV1\x09\x00\x00\x00\x00", "NAV1\x01\xff\xff\xff\xff", "NAV1\x01\x00\x00\x00\x02ab"} {
43+ if err := WriteCommand(&b, ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}, Output: strings.Repeat("x", ports.MaxOutputName+1)}); err == nil {
44+ t.Fatal("oversized output name encoded")
45+ }
46+ for _, in := range []string{"", "NAV2", "XXXX\x01\x00\x00\x00\x00", "NAV1\x01\x00\x00\x00\x09\x00\x00\x00\x00\x00\x00\x00\x00\x00", "NAV2\x09\x00\x00\x00\x00", "NAV2\x01\xff\xff\xff\xff", "NAV2\x01\x00\x00\x00\x02ab"} {
4347 if _, err := ReadCommand(bytes.NewReader([]byte(in))); err == nil {
4448 t.Fatalf("%q accepted", in)
4549 }
@@ -72,24 +76,31 @@ func TestEventRoundTripAndNoUnlockRequestExists(t *testing.T) {
7276 if _, err := ReadEvent(&c); err == nil {
7377 t.Fatal("command parsed as event")
7478 }
75- if _, err := ReadCommand(bytes.NewReader([]byte("NAV1\x04\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x01"))); err == nil {
79+ if _, err := ReadCommand(bytes.NewReader([]byte("NAV2\x04\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x01"))); err == nil {
7680 t.Fatal("event parsed as command")
7781 }
7882 }
7983
80-func TestReadCommandBoundsPINSource(t *testing.T) {
81- // Hand-build a lock frame whose PINSource is 17 bytes: the writer refuses
82- // it, so craft the payload directly.
83- p := binary.BigEndian.AppendUint64(nil, 1)
84- for _, f := range [][]byte{bytes.Repeat([]byte("x"), 17), nil, nil} {
85- p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
86- p = append(p, f...)
87- }
88- var b bytes.Buffer
89- if err := writeFrame(&b, kindLock, p); err != nil {
90- t.Fatal(err)
91- }
92- if _, err := ReadCommand(&b); err == nil {
93- t.Fatal("oversize PINSource accepted")
84+func TestReadCommandBoundsLockFields(t *testing.T) {
85+ // Hand-build lock frames the writer refuses: an oversize PINSource or
86+ // Output, and the older three-field layout.
87+ long := func(n int) []byte { return bytes.Repeat([]byte("x"), n) }
88+ for name, fields := range map[string][][]byte{
89+ "oversize PINSource": {long(17), nil, nil, nil},
90+ "oversize Output": {nil, nil, nil, long(ports.MaxOutputName + 1)},
91+ "three fields": {nil, nil, nil},
92+ } {
93+ p := binary.BigEndian.AppendUint64(nil, 1)
94+ for _, f := range fields {
95+ p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
96+ p = append(p, f...)
97+ }
98+ var b bytes.Buffer
99+ if err := writeFrame(&b, kindLock, p); err != nil {
100+ t.Fatal(err)
101+ }
102+ if _, err := ReadCommand(&b); err == nil {
103+ t.Fatalf("%s accepted", name)
104+ }
94105 }
95106 }
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "encoding/binary"5 "encoding/binary"
6 "reflect"6 "reflect"
7+ "strings"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
@@ -12,7 +13,7 @@ import (
12 13
13 func TestCommandRoundTrip(t *testing.T) {14 func TestCommandRoundTrip(t *testing.T) {
14 var b bytes.Buffer15 var b bytes.Buffer
15- lock := ports.VisualLock{Generation: 4, Auth: ports.AuthBootstrap{Generation: 4, PINSource: ports.PINSourceEnv, PINReference: "r", EnvPIN: []byte("123456")}}16+ lock := ports.VisualLock{Generation: 4, Auth: ports.AuthBootstrap{Generation: 4, PINSource: ports.PINSourceEnv, PINReference: "r", EnvPIN: []byte("123456")}, Output: "DP-2"}
16 for _, c := range []ports.VisualCommand{ports.VisualFade{Black: true, Duration: 2 * time.Second}, ports.VisualFade{}, lock} {17 for _, c := range []ports.VisualCommand{ports.VisualFade{Black: true, Duration: 2 * time.Second}, ports.VisualFade{}, lock} {
17 if err := WriteCommand(&b, c); err != nil {18 if err := WriteCommand(&b, c); err != nil {
18 t.Fatal(err)19 t.Fatal(err)
@@ -26,7 +27,7 @@ func TestCommandRoundTrip(t *testing.T) {
26 }27 }
27 c, err := ReadCommand(&b)28 c, err := ReadCommand(&b)
28 got, ok := c.(ports.VisualLock)29 got, ok := c.(ports.VisualLock)
29- if err != nil || !ok || got.Generation != 4 || got.Auth.Generation != 4 || got.Auth.PINSource != ports.PINSourceEnv || got.Auth.PINReference != "r" || string(got.Auth.EnvPIN) != "123456" {30+ if err != nil || !ok || got.Generation != 4 || got.Auth.Generation != 4 || got.Auth.PINSource != ports.PINSourceEnv || got.Auth.PINReference != "r" || string(got.Auth.EnvPIN) != "123456" || got.Output != "DP-2" {
30 t.Fatalf("%#v %v", c, err)31 t.Fatalf("%#v %v", c, err)
31 }32 }
32 }33 }
@@ -39,7 +40,10 @@ func TestCommandRejectsMismatchedGenerationAndGarbage(t *testing.T) {
39 if err := WriteCommand(&b, ports.VisualLock{}); err == nil {40 if err := WriteCommand(&b, ports.VisualLock{}); err == nil {
40 t.Fatal("zero generation encoded")41 t.Fatal("zero generation encoded")
41 }42 }
42- for _, in := range []string{"", "NAV1", "XXXX\x01\x00\x00\x00\x00", "NAV1\x09\x00\x00\x00\x00", "NAV1\x01\xff\xff\xff\xff", "NAV1\x01\x00\x00\x00\x02ab"} {43+ if err := WriteCommand(&b, ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}, Output: strings.Repeat("x", ports.MaxOutputName+1)}); err == nil {
44+ t.Fatal("oversized output name encoded")
45+ }
46+ for _, in := range []string{"", "NAV2", "XXXX\x01\x00\x00\x00\x00", "NAV1\x01\x00\x00\x00\x09\x00\x00\x00\x00\x00\x00\x00\x00\x00", "NAV2\x09\x00\x00\x00\x00", "NAV2\x01\xff\xff\xff\xff", "NAV2\x01\x00\x00\x00\x02ab"} {
43 if _, err := ReadCommand(bytes.NewReader([]byte(in))); err == nil {47 if _, err := ReadCommand(bytes.NewReader([]byte(in))); err == nil {
44 t.Fatalf("%q accepted", in)48 t.Fatalf("%q accepted", in)
45 }49 }
@@ -72,24 +76,31 @@ func TestEventRoundTripAndNoUnlockRequestExists(t *testing.T) {
72 if _, err := ReadEvent(&c); err == nil {76 if _, err := ReadEvent(&c); err == nil {
73 t.Fatal("command parsed as event")77 t.Fatal("command parsed as event")
74 }78 }
75- if _, err := ReadCommand(bytes.NewReader([]byte("NAV1\x04\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x01"))); err == nil {79+ if _, err := ReadCommand(bytes.NewReader([]byte("NAV2\x04\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x01"))); err == nil {
76 t.Fatal("event parsed as command")80 t.Fatal("event parsed as command")
77 }81 }
78 }82 }
79 83
80-func TestReadCommandBoundsPINSource(t *testing.T) {84+func TestReadCommandBoundsLockFields(t *testing.T) {
81- // Hand-build a lock frame whose PINSource is 17 bytes: the writer refuses85+ // Hand-build lock frames the writer refuses: an oversize PINSource or
82- // it, so craft the payload directly.86+ // Output, and the older three-field layout.
83- p := binary.BigEndian.AppendUint64(nil, 1)87+ long := func(n int) []byte { return bytes.Repeat([]byte("x"), n) }
84- for _, f := range [][]byte{bytes.Repeat([]byte("x"), 17), nil, nil} {88+ for name, fields := range map[string][][]byte{
85- p = binary.BigEndian.AppendUint32(p, uint32(len(f)))89+ "oversize PINSource": {long(17), nil, nil, nil},
86- p = append(p, f...)90+ "oversize Output": {nil, nil, nil, long(ports.MaxOutputName + 1)},
87- }91+ "three fields": {nil, nil, nil},
88- var b bytes.Buffer92+ } {
89- if err := writeFrame(&b, kindLock, p); err != nil {93+ p := binary.BigEndian.AppendUint64(nil, 1)
90- t.Fatal(err)94+ for _, f := range fields {
91- }95+ p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
92- if _, err := ReadCommand(&b); err == nil {96+ p = append(p, f...)
93- t.Fatal("oversize PINSource accepted")97+ }
98+ var b bytes.Buffer
99+ if err := writeFrame(&b, kindLock, p); err != nil {
100+ t.Fatal(err)
101+ }
102+ if _, err := ReadCommand(&b); err == nil {
103+ t.Fatalf("%s accepted", name)
104+ }
94 }105 }
95 }106 }
M internal/app/owner.go +1 -1
@@ -358,7 +358,7 @@ func (o *Owner) startLock(ctx context.Context) error {
358358 if !o.lockWanted() || o.lockGen == 0 {
359359 return nil
360360 }
361- err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Spawn: o.spawn()})
361+ err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Output: o.cfg.LockOutput, Spawn: o.spawn()})
362362 if err != nil {
363363 o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")
364364 o.armRetry()
@@ -358,7 +358,7 @@ func (o *Owner) startLock(ctx context.Context) error {
358 if !o.lockWanted() || o.lockGen == 0 {358 if !o.lockWanted() || o.lockGen == 0 {
359 return nil359 return nil
360 }360 }
361- err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Spawn: o.spawn()})361+ err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Output: o.cfg.LockOutput, Spawn: o.spawn()})
362 if err != nil {362 if err != nil {
363 o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")363 o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")
364 o.armRetry()364 o.armRetry()
M internal/app/owner_test.go +4 -2
@@ -213,7 +213,9 @@ func TestIdleActionsAreIndependentAndDisabledOnesNeverFire(t *testing.T) {
213213 }
214214
215215 func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
216- h := newHarness(t, cfgOf(step, 2*step, 3*step, 0), nil)
216+ cfg := cfgOf(step, 2*step, 3*step, 0)
217+ cfg.LockOutput = "DP-2"
218+ h := newHarness(t, cfg, nil)
217219 locks := h.lockCapture()
218220 fades := make(chan ports.VisualFade, 4)
219221 h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
@@ -223,7 +225,7 @@ func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
223225 h.send(ports.WaylandIdle{After: after})
224226 }
225227 l := lockOf(t, locks)
226- if l.Generation != 1 || l.Auth.Generation != 1 {
228+ if l.Generation != 1 || l.Auth.Generation != 1 || l.Output != "DP-2" {
227229 t.Fatalf("lock %+v", l)
228230 }
229231 h.visEv <- ports.LockConfirmed{Generation: l.Generation}
@@ -213,7 +213,9 @@ func TestIdleActionsAreIndependentAndDisabledOnesNeverFire(t *testing.T) {
213 }213 }
214 214
215 func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {215 func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
216- h := newHarness(t, cfgOf(step, 2*step, 3*step, 0), nil)216+ cfg := cfgOf(step, 2*step, 3*step, 0)
217+ cfg.LockOutput = "DP-2"
218+ h := newHarness(t, cfg, nil)
217 locks := h.lockCapture()219 locks := h.lockCapture()
218 fades := make(chan ports.VisualFade, 4)220 fades := make(chan ports.VisualFade, 4)
219 h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })221 h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
@@ -223,7 +225,7 @@ func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
223 h.send(ports.WaylandIdle{After: after})225 h.send(ports.WaylandIdle{After: after})
224 }226 }
225 l := lockOf(t, locks)227 l := lockOf(t, locks)
226- if l.Generation != 1 || l.Auth.Generation != 1 {228+ if l.Generation != 1 || l.Auth.Generation != 1 || l.Output != "DP-2" {
227 t.Fatalf("lock %+v", l)229 t.Fatalf("lock %+v", l)
228 }230 }
229 h.visEv <- ports.LockConfirmed{Generation: l.Generation}231 h.visEv <- ports.LockConfirmed{Generation: l.Generation}
M internal/ports/config.go +12 -0
@@ -15,7 +15,16 @@ type Config struct {
1515 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
1616 FadeDuration time.Duration
1717 Auth AuthConfig
18+ // LockOutput names the output (wl_output name, e.g. DP-2) that shows the
19+ // lock prompt. Empty, or absent: the output whose lock surface has
20+ // keyboard focus, initially the first advertised one. A change applies
21+ // from the next lock.
22+ LockOutput string
1823 }
24+
25+// MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
26+// names, which could never match.
27+const MaxOutputName = 64
1928 type AuthMode string
2029
2130 const (
@@ -48,6 +57,9 @@ func (c Config) Validate() error {
4857 return fmt.Errorf("duration outside 0..24h")
4958 }
5059 }
60+ if len(c.LockOutput) > MaxOutputName || !utf8.ValidString(c.LockOutput) || strings.ContainsFunc(c.LockOutput, unicode.IsControl) {
61+ return fmt.Errorf("invalid lock output name")
62+ }
5163 a := c.Auth
5264 for _, r := range []string{string(a.Mode), string(a.PINSource), a.PINEnv, a.PINEntry} {
5365 if !utf8.ValidString(r) {
@@ -15,7 +15,16 @@ type Config struct {
15 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration15 LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration
16 FadeDuration time.Duration16 FadeDuration time.Duration
17 Auth AuthConfig17 Auth AuthConfig
18+ // LockOutput names the output (wl_output name, e.g. DP-2) that shows the
19+ // lock prompt. Empty, or absent: the output whose lock surface has
20+ // keyboard focus, initially the first advertised one. A change applies
21+ // from the next lock.
22+ LockOutput string
18 }23 }
24+
25+// MaxOutputName bounds LockOutput: neferclient truncates longer wl_output
26+// names, which could never match.
27+const MaxOutputName = 64
19 type AuthMode string28 type AuthMode string
20 29
21 const (30 const (
@@ -48,6 +57,9 @@ func (c Config) Validate() error {
48 return fmt.Errorf("duration outside 0..24h")57 return fmt.Errorf("duration outside 0..24h")
49 }58 }
50 }59 }
60+ if len(c.LockOutput) > MaxOutputName || !utf8.ValidString(c.LockOutput) || strings.ContainsFunc(c.LockOutput, unicode.IsControl) {
61+ return fmt.Errorf("invalid lock output name")
62+ }
51 a := c.Auth63 a := c.Auth
52 for _, r := range []string{string(a.Mode), string(a.PINSource), a.PINEnv, a.PINEntry} {64 for _, r := range []string{string(a.Mode), string(a.PINSource), a.PINEnv, a.PINEntry} {
53 if !utf8.ValidString(r) {65 if !utf8.ValidString(r) {
M internal/ports/visual.go +3 -1
@@ -58,10 +58,12 @@ type VisualSpawn struct{ OmitEnv []string }
5858 // VisualLock carries auth metadata only (config + the privately captured
5959 // startup PIN value when the env source is selected). Auth.Generation equals
6060 // Generation and is the nonzero epoch scoping every auth frame. The visual,
61-// not the daemon, spawns the auth worker and forwards Auth to it.
61+// not the daemon, spawns the auth worker and forwards Auth to it. Output is
62+// Config.LockOutput.
6263 type VisualLock struct {
6364 Generation Generation
6465 Auth AuthBootstrap
66+ Output string
6567 Spawn VisualSpawn
6668 }
6769
@@ -58,10 +58,12 @@ type VisualSpawn struct{ OmitEnv []string }
58 // VisualLock carries auth metadata only (config + the privately captured58 // VisualLock carries auth metadata only (config + the privately captured
59 // startup PIN value when the env source is selected). Auth.Generation equals59 // startup PIN value when the env source is selected). Auth.Generation equals
60 // Generation and is the nonzero epoch scoping every auth frame. The visual,60 // Generation and is the nonzero epoch scoping every auth frame. The visual,
61-// not the daemon, spawns the auth worker and forwards Auth to it.61+// not the daemon, spawns the auth worker and forwards Auth to it. Output is
62+// Config.LockOutput.
62 type VisualLock struct {63 type VisualLock struct {
63 Generation Generation64 Generation Generation
64 Auth AuthBootstrap65 Auth AuthBootstrap
66+ Output string
65 Spawn VisualSpawn67 Spawn VisualSpawn
66 }68 }
67 69

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close