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 | |||
| 71 | 71 | | `fade.after` | duration or `off` | `6m` | | |
| 72 | 72 | | `fade.duration` | duration | `2s` | | |
| 73 | 73 | | `lock.after` | duration or `off` | `5m` | | |
| 74 | + | | `lock.output` | output name (e.g. `DP-2`) | none | | |
| 74 | 75 | | `screens.off-after` | duration or `off` | `7m` | | |
| 75 | 76 | | `sleep.after` | duration or `off` | `20m` | | |
| 76 | 77 | | `auth.mode` | `password` or `pin` | `password` | | |
| @@ -80,6 +81,8 @@ auth.mode = password | |||
| 80 | 81 | ||
| 81 | 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 | 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 | 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. | |
| @@ -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 | |||
| 9 | 9 | screens.off-after = 7m | |
| 10 | 10 | 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 | 16 | # password (PAM service /etc/pam.d/neferafk) or pin. | |
| 13 | 17 | auth.mode = password | |
| 14 | 18 | # 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 = 7m | 9 | screens.off-after = 7m | ||
| 10 | sleep.after = 20m | 10 | 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 = password | 17 | auth.mode = password | ||
| 14 | # PIN mode: 6 to 32 digits from `pass show ENTRY` (or auth.pin-source = env | 18 | # 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) { | |||
| 80 | 80 | target = &cfg.OffAfter | |
| 81 | 81 | case "sleep.after": | |
| 82 | 82 | target = &cfg.SleepAfter | |
| 83 | + | case "lock.output": | |
| 84 | + | cfg.LockOutput = value | |
| 83 | 85 | case "auth.mode": | |
| 84 | 86 | cfg.Auth.Mode = ports.AuthMode(value) | |
| 85 | 87 | case "auth.pin-source": | |
| @@ -80,6 +80,8 @@ func Parse(r io.Reader) (ports.Config, error) { | |||||
| 80 | target = &cfg.OffAfter | 80 | target = &cfg.OffAfter | ||
| 81 | case "sleep.after": | 81 | case "sleep.after": | ||
| 82 | target = &cfg.SleepAfter | 82 | 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) { | |||
| 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 | 42 | func TestPassReferenceMetadataOnly(t *testing.T) { | |
| 29 | 43 | c, err := Parse(strings.NewReader("auth.pin-source=pass\nauth.pin-entry=desktop/neferafk\n")) | |
| 30 | 44 | 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 { | |||
| 36 | 36 | // lockConfig wires the session lock to its caller. Every callback runs on the | |
| 37 | 37 | // lock's owner goroutine. | |
| 38 | 38 | 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. | |
| 42 | 42 | View func(*nefergui.Frame, lockState) | |
| 43 | + | // Output is the preferred host output name; empty for none. | |
| 44 | + | Output string | |
| 43 | 45 | // OnLocked runs once when the compositor confirms the lock. | |
| 44 | 46 | OnLocked func() | |
| 45 | 47 | // OnOutputError reports an output added after the lock was acquired that | |
| @@ -145,6 +147,7 @@ type locker struct { | |||
| 145 | 147 | seat *neferclient.Seat | |
| 146 | 148 | secret *neferclient.SecretBuffer | |
| 147 | 149 | host *screen | |
| 150 | + | focus *screen // has keyboard focus; may differ from host on the preferred output | |
| 148 | 151 | initial map[uint32]bool // outputs present at acquisition: their failures abort | |
| 149 | 152 | status lockStatus | |
| 150 | 153 | unlock bool // requested; honoured once locked | |
| @@ -188,12 +191,19 @@ func (l *locker) cover(o neferclient.Output) error { | |||
| 188 | 191 | s := newScreen(l.conn, surf, o, false) | |
| 189 | 192 | s.view = func(f *nefergui.Frame) { l.view(f, s) } | |
| 190 | 193 | 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) { | |
| 192 | 197 | l.setHost(s) | |
| 193 | 198 | } | |
| 194 | 199 | 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 | 207 | // view shows the lock content on the host output and plain black elsewhere. | |
| 198 | 208 | func (l *locker) view(f *nefergui.Frame, s *screen) { | |
| 199 | 209 | if s != l.host { | |
| @@ -249,14 +259,21 @@ func (l *locker) errorPolicy(err error) error { | |||
| 249 | 259 | 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 | 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 | 269 | return | |
| 256 | 270 | } | |
| 257 | - | l.host = nil | |
| 271 | + | l.host = l.focus | |
| 258 | 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 | 277 | l.host = s | |
| 261 | 278 | } | |
| 262 | 279 | } | |
| @@ -308,7 +325,14 @@ func (l *locker) LockFinished() { | |||
| 308 | 325 | } | |
| 309 | 326 | ||
| 310 | 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 | 336 | l.setHost(s) | |
| 313 | 337 | } | |
| 314 | 338 | } | |
| @@ -36,10 +36,12 @@ type lockState struct { | |||||
| 36 | // lockConfig wires the session lock to its caller. Every callback runs on the | 36 | // 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 whose | 39 | + | // View builds the content of the output that hosts it: Output while it |
| 40 | - | // surface has keyboard focus, initially the lowest output. Other outputs | 40 | + | // 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 that | 47 | // OnOutputError reports an output added after the lock was acquired that | ||
| @@ -145,6 +147,7 @@ type locker struct { | |||||
| 145 | seat *neferclient.Seat | 147 | seat *neferclient.Seat | ||
| 146 | secret *neferclient.SecretBuffer | 148 | secret *neferclient.SecretBuffer | ||
| 147 | host *screen | 149 | 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 abort | 151 | initial map[uint32]bool // outputs present at acquisition: their failures abort | ||
| 149 | status lockStatus | 152 | status lockStatus | ||
| 150 | unlock bool // requested; honoured once locked | 153 | 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 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) { | |||
| 192 | l.setHost(s) | 197 | l.setHost(s) | ||
| 193 | } | 198 | } | ||
| 194 | return nil | 199 | 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 nil | 259 | 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 | return | 269 | return | ||
| 256 | } | 270 | } | ||
| 257 | - | l.host = nil | 271 | + | 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 = s | 277 | 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 { | |||
| 220 | 220 | } | |
| 221 | 221 | go func() { defer close(l.authDone); ctl.run(lctx) }() | |
| 222 | 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 | 226 | return l | |
| 225 | 227 | } | |
| 226 | 228 | ||
| @@ -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 l | 226 | return l | ||
| 225 | } | 227 | } | ||
| 226 | 228 | ||||
M internal/adapters/visual/screen.go +10 -0
| @@ -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 | 346 | // covers reports whether an output already has a screen. | |
| 337 | 347 | func (ss *screens) covers(global uint32) bool { | |
| 338 | 348 | 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 @@ | |||
| 4 | 4 | // | |
| 5 | 5 | // Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon): | |
| 6 | 6 | // | |
| 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. | |
| 8 | 11 | // | |
| 9 | 12 | // 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 | |
| 12 | 15 | // {generation u64}. Process exit is observed by the launcher, not sent. | |
| 13 | 16 | package visualproc | |
| 14 | 17 | ||
| @@ -28,13 +31,15 @@ const ( | |||
| 28 | 31 | kindLock | |
| 29 | 32 | kindConfirmed | |
| 30 | 33 | kindReleased | |
| 31 | - | maxPayload = 8 + 3*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret | |
| 34 | + | maxPayload = 8 + 4*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret + ports.MaxOutputName | |
| 32 | 35 | ) | |
| 33 | 36 | ||
| 37 | + | const magic = "NAV2" | |
| 38 | + | ||
| 34 | 39 | func writeFrame(w io.Writer, kind byte, payload []byte) error { | |
| 35 | 40 | buf := make([]byte, 9+len(payload)) | |
| 36 | 41 | defer clear(buf) | |
| 37 | - | copy(buf, "NAV1") | |
| 42 | + | copy(buf, magic) | |
| 38 | 43 | buf[4] = kind | |
| 39 | 44 | binary.BigEndian.PutUint32(buf[5:], uint32(len(payload))) | |
| 40 | 45 | copy(buf[9:], payload) | |
| @@ -54,7 +59,7 @@ func readFrame(r io.Reader) (byte, []byte, error) { | |||
| 54 | 59 | return 0, nil, err | |
| 55 | 60 | } | |
| 56 | 61 | n := binary.BigEndian.Uint32(h[5:]) | |
| 57 | - | if string(h[:4]) != "NAV1" || n > maxPayload { | |
| 62 | + | if string(h[:4]) != magic || n > maxPayload { | |
| 58 | 63 | return 0, nil, ErrProtocol | |
| 59 | 64 | } | |
| 60 | 65 | p := make([]byte, n) | |
| @@ -78,8 +83,8 @@ func WriteCommand(w io.Writer, cmd ports.VisualCommand) error { | |||
| 78 | 83 | return writeFrame(w, kindFade, p) | |
| 79 | 84 | case ports.VisualLock: | |
| 80 | 85 | 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 { | |
| 83 | 88 | return ErrProtocol | |
| 84 | 89 | } | |
| 85 | 90 | p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation)) | |
| @@ -117,7 +122,7 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) { | |||
| 117 | 122 | } | |
| 118 | 123 | gen := binary.BigEndian.Uint64(p) | |
| 119 | 124 | rest := p[8:] | |
| 120 | - | var f [3][]byte | |
| 125 | + | var f [4][]byte | |
| 121 | 126 | for i := range f { | |
| 122 | 127 | if len(rest) < 4 { | |
| 123 | 128 | return nil, ErrProtocol | |
| @@ -129,10 +134,10 @@ func ReadCommand(r io.Reader) (ports.VisualCommand, error) { | |||
| 129 | 134 | f[i] = rest[4 : 4+n] | |
| 130 | 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 | 138 | 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]...)}}, 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 | |
| 136 | 141 | } | |
| 137 | 142 | return nil, ErrProtocol | |
| 138 | 143 | } | |
| @@ -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 | 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. | |||
| 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 LockReleased | 14 | + | // 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 visualproc | 16 | package visualproc | ||
| 14 | 17 | ||||
| @@ -28,13 +31,15 @@ const ( | |||||
| 28 | kindLock | 31 | kindLock | ||
| 29 | kindConfirmed | 32 | kindConfirmed | ||
| 30 | kindReleased | 33 | kindReleased | ||
| 31 | - | maxPayload = 8 + 3*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret | 34 | + | 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] = kind | 43 | 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, err | 59 | 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, ErrProtocol | 63 | 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.Auth | 85 | 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 ErrProtocol | 88 | 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][]byte | 125 | + | 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, ErrProtocol | 128 | 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, ErrProtocol | 138 | 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]...)}}, 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 |
| 136 | } | 141 | } | ||
| 137 | return nil, ErrProtocol | 142 | return nil, ErrProtocol | ||
| 138 | } | 143 | } | ||
M internal/adapters/visualproc/wire_test.go +29 -18
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/binary" | |
| 6 | 6 | "reflect" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -12,7 +13,7 @@ import ( | |||
| 12 | 13 | ||
| 13 | 14 | func TestCommandRoundTrip(t *testing.T) { | |
| 14 | 15 | 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 | 17 | for _, c := range []ports.VisualCommand{ports.VisualFade{Black: true, Duration: 2 * time.Second}, ports.VisualFade{}, lock} { | |
| 17 | 18 | if err := WriteCommand(&b, c); err != nil { | |
| 18 | 19 | t.Fatal(err) | |
| @@ -26,7 +27,7 @@ func TestCommandRoundTrip(t *testing.T) { | |||
| 26 | 27 | } | |
| 27 | 28 | c, err := ReadCommand(&b) | |
| 28 | 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 | 31 | t.Fatalf("%#v %v", c, err) | |
| 31 | 32 | } | |
| 32 | 33 | } | |
| @@ -39,7 +40,10 @@ func TestCommandRejectsMismatchedGenerationAndGarbage(t *testing.T) { | |||
| 39 | 40 | if err := WriteCommand(&b, ports.VisualLock{}); err == nil { | |
| 40 | 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 | 47 | if _, err := ReadCommand(bytes.NewReader([]byte(in))); err == nil { | |
| 44 | 48 | t.Fatalf("%q accepted", in) | |
| 45 | 49 | } | |
| @@ -72,24 +76,31 @@ func TestEventRoundTripAndNoUnlockRequestExists(t *testing.T) { | |||
| 72 | 76 | if _, err := ReadEvent(&c); err == nil { | |
| 73 | 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 | 80 | t.Fatal("event parsed as command") | |
| 77 | 81 | } | |
| 78 | 82 | } | |
| 79 | 83 | ||
| 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 | + | } | |
| 94 | 105 | } | |
| 95 | 106 | } | |
| @@ -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.Buffer | 15 | 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 refuses | 85 | + | // 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.Buffer | 92 | + | } { |
| 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 { | |||
| 358 | 358 | if !o.lockWanted() || o.lockGen == 0 { | |
| 359 | 359 | 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 | 362 | if err != nil { | |
| 363 | 363 | o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable") | |
| 364 | 364 | 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 nil | 359 | 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) { | |||
| 213 | 213 | } | |
| 214 | 214 | ||
| 215 | 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 | 219 | locks := h.lockCapture() | |
| 218 | 220 | fades := make(chan ports.VisualFade, 4) | |
| 219 | 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 | 225 | h.send(ports.WaylandIdle{After: after}) | |
| 224 | 226 | } | |
| 225 | 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 | 229 | t.Fatalf("lock %+v", l) | |
| 228 | 230 | } | |
| 229 | 231 | 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 { | |||
| 15 | 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | |
| 16 | 16 | FadeDuration time.Duration | |
| 17 | 17 | 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 | 28 | type AuthMode string | |
| 20 | 29 | ||
| 21 | 30 | const ( | |
| @@ -48,6 +57,9 @@ func (c Config) Validate() error { | |||
| 48 | 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 | 63 | a := c.Auth | |
| 52 | 64 | for _, r := range []string{string(a.Mode), string(a.PINSource), a.PINEnv, a.PINEntry} { | |
| 53 | 65 | if !utf8.ValidString(r) { | |
| @@ -15,7 +15,16 @@ type Config struct { | |||||
| 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | 15 | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | ||
| 16 | FadeDuration time.Duration | 16 | FadeDuration time.Duration | ||
| 17 | Auth AuthConfig | 17 | 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 string | 28 | 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.Auth | 63 | 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 } | |||
| 58 | 58 | // VisualLock carries auth metadata only (config + the privately captured | |
| 59 | 59 | // startup PIN value when the env source is selected). Auth.Generation equals | |
| 60 | 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 | 63 | type VisualLock struct { | |
| 63 | 64 | Generation Generation | |
| 64 | 65 | Auth AuthBootstrap | |
| 66 | + | Output string | |
| 65 | 67 | Spawn VisualSpawn | |
| 66 | 68 | } | |
| 67 | 69 | ||
| @@ -58,10 +58,12 @@ type VisualSpawn struct{ OmitEnv []string } | |||||
| 58 | // VisualLock carries auth metadata only (config + the privately captured | 58 | // VisualLock carries auth metadata only (config + the privately captured | ||
| 59 | // startup PIN value when the env source is selected). Auth.Generation equals | 59 | // 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 Generation | 64 | Generation Generation | ||
| 64 | Auth AuthBootstrap | 65 | Auth AuthBootstrap | ||
| 66 | + | Output string | |||
| 65 | Spawn VisualSpawn | 67 | Spawn VisualSpawn | ||
| 66 | } | 68 | } | ||
| 67 | 69 | ||||