detached 61fa9c2

refactor(visual): run the lock and fade on neferclient with NeferGUI renderers (#8)

* refactor(visual): run the lock and fade on neferclient with NeferGUI renderers

* fix(visual): lock at once, surface handler errors and refuse truncated secrets

* fix(visual): leave room for multi-code-point keys and wait for the fade in tests

* fix(visual): end the lock only on keyboard failures and log other event errors
commit
61fa9c2dce2ce46211fbd391bc463f6607589f3a
author
bnema · · 2026-10-02 02:55 UTC
committer
GitHub · · 2026-10-02 02:55 UTC
parent
0a8834d
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1168 -141
M AGENTS.md +1 -1
@@ -6,5 +6,5 @@
66 - Cross-layer seams live in internal/ports; generated mocks in internal/mocks/ports. Adapter-internal seams stay unexported with generated same-package test mocks. Add .mockery.yml entries and verify generation.
77 - Signed conventional commits only; no remote/publication/installation or live lock/off/suspend/real credential attempt without authorization.
88 - Credentials travel directly from visual process to the isolated authentication worker, never through policy daemon, render models, logs, argv or debug output.
9-- Pure Go/purego PAM via purego-pam; no C helper or renderer fallback. Standard advertised Wayland protocols and existing NeferGUI Vulkan/DMA-BUF/explicit-sync rendering only.
9+- Pure Go/purego PAM via purego-pam; no C helper or renderer fallback. Standard advertised Wayland protocols only: neferclient for surfaces and input, go-wayland-bindings for the control connection, NeferGUI Vulkan/DMA-BUF/explicit-sync rendering. Headless tests (NEFERAFK_HEADLESS) only, never a real session lock.
1010 - Flat key = value config with independent disableable lock/fade/output-off/suspend deadlines. Returning activity never unlocks.
@@ -6,5 +6,5 @@
6 - Cross-layer seams live in internal/ports; generated mocks in internal/mocks/ports. Adapter-internal seams stay unexported with generated same-package test mocks. Add .mockery.yml entries and verify generation.6 - Cross-layer seams live in internal/ports; generated mocks in internal/mocks/ports. Adapter-internal seams stay unexported with generated same-package test mocks. Add .mockery.yml entries and verify generation.
7 - Signed conventional commits only; no remote/publication/installation or live lock/off/suspend/real credential attempt without authorization.7 - Signed conventional commits only; no remote/publication/installation or live lock/off/suspend/real credential attempt without authorization.
8 - Credentials travel directly from visual process to the isolated authentication worker, never through policy daemon, render models, logs, argv or debug output.8 - Credentials travel directly from visual process to the isolated authentication worker, never through policy daemon, render models, logs, argv or debug output.
9-- Pure Go/purego PAM via purego-pam; no C helper or renderer fallback. Standard advertised Wayland protocols and existing NeferGUI Vulkan/DMA-BUF/explicit-sync rendering only.9+- Pure Go/purego PAM via purego-pam; no C helper or renderer fallback. Standard advertised Wayland protocols only: neferclient for surfaces and input, go-wayland-bindings for the control connection, NeferGUI Vulkan/DMA-BUF/explicit-sync rendering. Headless tests (NEFERAFK_HEADLESS) only, never a real session lock.
10 - Flat key = value config with independent disableable lock/fade/output-off/suspend deadlines. Returning activity never unlocks.10 - Flat key = value config with independent disableable lock/fade/output-off/suspend deadlines. Returning activity never unlocks.
M README.md +7 -1
@@ -2,7 +2,7 @@
22
33 NeferAFK is an idle daemon for Wayland compositors. It fades the screen, locks the session, turns the outputs off and suspends the machine after configurable idle delays.
44
5-It uses standard protocols only: `ext-idle-notify-v1`, `ext-session-lock-v1`, `wlr-output-power-management-v1` and systemd-logind. The lock screen renders with [NeferGUI](https://github.com/bnema/nefergui) (Vulkan, DMA-BUF, explicit sync).
5+It uses standard protocols only: `ext-idle-notify-v1`, `ext-session-lock-v1`, `wlr-output-power-management-v1` and systemd-logind. The lock screen and the fade use [neferclient](https://github.com/bnema/neferclient) for their Wayland surfaces and draw with [NeferGUI](https://github.com/bnema/nefergui) (Vulkan, DMA-BUF, explicit sync).
66
77 ## Behaviour
88
@@ -92,6 +92,12 @@ make race # race tests (CGO_ENABLED=1)
9292 make mocks-check # Mockery v3 mocks are up to date
9393 ```
9494
95+The lock and fade loops also have headless tests against a nested NeferWL compositor (two virtual outputs, a GPU render node needed). They never touch the running session:
96+
97+```sh
98+NEFERAFK_HEADLESS=/usr/sbin/neferwl go test -run Headless ./internal/adapters/visual/
99+```
100+
95101 ## License
96102
97103 [GNU GPL v3](LICENSE).
@@ -2,7 +2,7 @@
2 2
3 NeferAFK is an idle daemon for Wayland compositors. It fades the screen, locks the session, turns the outputs off and suspends the machine after configurable idle delays.3 NeferAFK is an idle daemon for Wayland compositors. It fades the screen, locks the session, turns the outputs off and suspends the machine after configurable idle delays.
4 4
5-It uses standard protocols only: `ext-idle-notify-v1`, `ext-session-lock-v1`, `wlr-output-power-management-v1` and systemd-logind. The lock screen renders with [NeferGUI](https://github.com/bnema/nefergui) (Vulkan, DMA-BUF, explicit sync).5+It uses standard protocols only: `ext-idle-notify-v1`, `ext-session-lock-v1`, `wlr-output-power-management-v1` and systemd-logind. The lock screen and the fade use [neferclient](https://github.com/bnema/neferclient) for their Wayland surfaces and draw with [NeferGUI](https://github.com/bnema/nefergui) (Vulkan, DMA-BUF, explicit sync).
6 6
7 ## Behaviour7 ## Behaviour
8 8
@@ -92,6 +92,12 @@ make race # race tests (CGO_ENABLED=1)
92 make mocks-check # Mockery v3 mocks are up to date92 make mocks-check # Mockery v3 mocks are up to date
93 ```93 ```
94 94
95+The lock and fade loops also have headless tests against a nested NeferWL compositor (two virtual outputs, a GPU render node needed). They never touch the running session:
96+
97+```sh
98+NEFERAFK_HEADLESS=/usr/sbin/neferwl go test -run Headless ./internal/adapters/visual/
99+```
100+
95 ## License101 ## License
96 102
97 [GNU GPL v3](LICENSE).103 [GNU GPL v3](LICENSE).
M go.mod +5 -3
@@ -3,9 +3,11 @@ module github.com/bnema/neferafk
33 go 1.27
44
55 require (
6- github.com/bnema/nefergui v0.4.0
6+ github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/neferclient v0.2.0
8+ github.com/bnema/nefergui v0.6.0
79 github.com/bnema/purego-pam v0.1.0
8- github.com/bnema/wlturbo v0.5.0
10+ github.com/bnema/wlturbo v0.6.2
911 github.com/bnema/zerowrap v1.4.1
1012 github.com/godbus/dbus/v5 v5.2.2
1113 github.com/stretchr/testify v1.12.1
@@ -15,7 +17,7 @@ require (
1517 require (
1618 github.com/bnema/purego v0.13.0-bnema.1 // indirect
1719 github.com/bnema/purego-vulkan v0.6.0 // indirect
18- github.com/bnema/purego-xkbcommon v0.1.0 // indirect
20+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
1921 github.com/go-text/typesetting v0.3.5 // indirect
2022 github.com/mattn/go-colorable v0.1.15 // indirect
2123 github.com/mattn/go-isatty v0.0.22 // indirect
@@ -3,9 +3,11 @@ module github.com/bnema/neferafk
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/nefergui v0.4.06+ github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/neferclient v0.2.0
8+ github.com/bnema/nefergui v0.6.0
7 github.com/bnema/purego-pam v0.1.09 github.com/bnema/purego-pam v0.1.0
8- github.com/bnema/wlturbo v0.5.010+ github.com/bnema/wlturbo v0.6.2
9 github.com/bnema/zerowrap v1.4.111 github.com/bnema/zerowrap v1.4.1
10 github.com/godbus/dbus/v5 v5.2.212 github.com/godbus/dbus/v5 v5.2.2
11 github.com/stretchr/testify v1.12.113 github.com/stretchr/testify v1.12.1
@@ -15,7 +17,7 @@ require (
15 require (17 require (
16 github.com/bnema/purego v0.13.0-bnema.1 // indirect18 github.com/bnema/purego v0.13.0-bnema.1 // indirect
17 github.com/bnema/purego-vulkan v0.6.0 // indirect19 github.com/bnema/purego-vulkan v0.6.0 // indirect
18- github.com/bnema/purego-xkbcommon v0.1.0 // indirect20+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
19 github.com/go-text/typesetting v0.3.5 // indirect21 github.com/go-text/typesetting v0.3.5 // indirect
20 github.com/mattn/go-colorable v0.1.15 // indirect22 github.com/mattn/go-colorable v0.1.15 // indirect
21 github.com/mattn/go-isatty v0.0.22 // indirect23 github.com/mattn/go-isatty v0.0.22 // indirect
M go.sum +10 -6 generated file: collapsed
M internal/adapters/visual/auth.go +10 -11
@@ -8,7 +8,6 @@ import (
88
99 "github.com/bnema/neferafk/internal/adapters/auth"
1010 "github.com/bnema/neferafk/internal/ports"
11- "github.com/bnema/nefergui"
1211 "github.com/bnema/zerowrap"
1312 )
1413
@@ -80,7 +79,7 @@ type authController struct {
8079 timeout time.Duration
8180
8281 submits chan []byte
83- status chan nefergui.LockStatus
82+ status chan lockStatus
8483 unlock chan struct{}
8584 wake chan struct{}
8685
@@ -99,7 +98,7 @@ type authController struct {
9998 func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController {
10099 return &authController{
101100 log: log.WithField("component", "visual-auth"), launcher: l, boot: boot, timeout: timeout,
102- submits: make(chan []byte, 1), status: make(chan nefergui.LockStatus, 8),
101+ submits: make(chan []byte, 1), status: make(chan lockStatus, 8),
103102 unlock: make(chan struct{}, 1), wake: make(chan struct{}, 1),
104103 }
105104 }
@@ -115,7 +114,7 @@ func (c *authController) submit(secret []byte) {
115114 }
116115 }
117116
118-func (c *authController) setStatus(s nefergui.LockStatus) {
117+func (c *authController) setStatus(s lockStatus) {
119118 for {
120119 select {
121120 case c.status <- s:
@@ -272,7 +271,7 @@ func (c *authController) workerLost(failed bool) {
272271 c.setPrompt(promptNone)
273272 }
274273 if failed || inFlight {
275- c.setStatus(nefergui.LockFailed)
274+ c.setStatus(lockFailed)
276275 }
277276 }
278277
@@ -285,12 +284,12 @@ func (c *authController) onSubmit(ctx context.Context, s []byte) {
285284 case phaseNoWorker:
286285 clear(c.pending)
287286 c.pending = s
288- c.setStatus(nefergui.LockBusy)
287+ c.setStatus(lockBusy)
289288 c.spawn(ctx)
290289 if c.w == nil { // launch failed
291290 clear(c.pending)
292291 c.pending = nil
293- c.setStatus(nefergui.LockFailed)
292+ c.setStatus(lockFailed)
294293 }
295294 case phaseStarting: // keep only the latest submission
296295 clear(c.pending)
@@ -308,7 +307,7 @@ func (c *authController) write(f ports.AuthFrame) bool {
308307
309308 func (c *authController) sendAttempt(s []byte) {
310309 c.attempt++
311- c.setStatus(nefergui.LockBusy)
310+ c.setStatus(lockBusy)
312311 c.ph = phaseAttempt
313312 c.arm()
314313 if !c.write(ports.AuthFrame{Kind: ports.AuthAttempt, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
@@ -317,7 +316,7 @@ func (c *authController) sendAttempt(s []byte) {
317316 }
318317
319318 func (c *authController) sendSecret(s []byte) {
320- c.setStatus(nefergui.LockBusy)
319+ c.setStatus(lockBusy)
321320 c.ph = phaseAttempt
322321 c.arm()
323322 if !c.write(ports.AuthFrame{Kind: ports.AuthSecret, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
@@ -369,7 +368,7 @@ func (c *authController) onFrame(m frameMsg) bool {
369368 }
370369 c.ph = phasePrompt
371370 c.setPrompt(promptMore)
372- c.setStatus(nefergui.LockIdle)
371+ c.setStatus(lockIdle)
373372 case ports.AuthStatus:
374373 // PAM text is deliberately never shown.
375374 case ports.AuthResult:
@@ -385,7 +384,7 @@ func (c *authController) onFrame(m frameMsg) bool {
385384 return true
386385 }
387386 c.ph = phaseReady
388- c.setStatus(nefergui.LockFailed)
387+ c.setStatus(lockFailed)
389388 default:
390389 c.workerLost(true)
391390 }
@@ -8,7 +8,6 @@ import (
8 8
9 "github.com/bnema/neferafk/internal/adapters/auth"9 "github.com/bnema/neferafk/internal/adapters/auth"
10 "github.com/bnema/neferafk/internal/ports"10 "github.com/bnema/neferafk/internal/ports"
11- "github.com/bnema/nefergui"
12 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
13 )12 )
14 13
@@ -80,7 +79,7 @@ type authController struct {
80 timeout time.Duration79 timeout time.Duration
81 80
82 submits chan []byte81 submits chan []byte
83- status chan nefergui.LockStatus82+ status chan lockStatus
84 unlock chan struct{}83 unlock chan struct{}
85 wake chan struct{}84 wake chan struct{}
86 85
@@ -99,7 +98,7 @@ type authController struct {
99 func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController {98 func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController {
100 return &authController{99 return &authController{
101 log: log.WithField("component", "visual-auth"), launcher: l, boot: boot, timeout: timeout,100 log: log.WithField("component", "visual-auth"), launcher: l, boot: boot, timeout: timeout,
102- submits: make(chan []byte, 1), status: make(chan nefergui.LockStatus, 8),101+ submits: make(chan []byte, 1), status: make(chan lockStatus, 8),
103 unlock: make(chan struct{}, 1), wake: make(chan struct{}, 1),102 unlock: make(chan struct{}, 1), wake: make(chan struct{}, 1),
104 }103 }
105 }104 }
@@ -115,7 +114,7 @@ func (c *authController) submit(secret []byte) {
115 }114 }
116 }115 }
117 116
118-func (c *authController) setStatus(s nefergui.LockStatus) {117+func (c *authController) setStatus(s lockStatus) {
119 for {118 for {
120 select {119 select {
121 case c.status <- s:120 case c.status <- s:
@@ -272,7 +271,7 @@ func (c *authController) workerLost(failed bool) {
272 c.setPrompt(promptNone)271 c.setPrompt(promptNone)
273 }272 }
274 if failed || inFlight {273 if failed || inFlight {
275- c.setStatus(nefergui.LockFailed)274+ c.setStatus(lockFailed)
276 }275 }
277 }276 }
278 277
@@ -285,12 +284,12 @@ func (c *authController) onSubmit(ctx context.Context, s []byte) {
285 case phaseNoWorker:284 case phaseNoWorker:
286 clear(c.pending)285 clear(c.pending)
287 c.pending = s286 c.pending = s
288- c.setStatus(nefergui.LockBusy)287+ c.setStatus(lockBusy)
289 c.spawn(ctx)288 c.spawn(ctx)
290 if c.w == nil { // launch failed289 if c.w == nil { // launch failed
291 clear(c.pending)290 clear(c.pending)
292 c.pending = nil291 c.pending = nil
293- c.setStatus(nefergui.LockFailed)292+ c.setStatus(lockFailed)
294 }293 }
295 case phaseStarting: // keep only the latest submission294 case phaseStarting: // keep only the latest submission
296 clear(c.pending)295 clear(c.pending)
@@ -308,7 +307,7 @@ func (c *authController) write(f ports.AuthFrame) bool {
308 307
309 func (c *authController) sendAttempt(s []byte) {308 func (c *authController) sendAttempt(s []byte) {
310 c.attempt++309 c.attempt++
311- c.setStatus(nefergui.LockBusy)310+ c.setStatus(lockBusy)
312 c.ph = phaseAttempt311 c.ph = phaseAttempt
313 c.arm()312 c.arm()
314 if !c.write(ports.AuthFrame{Kind: ports.AuthAttempt, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {313 if !c.write(ports.AuthFrame{Kind: ports.AuthAttempt, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
@@ -317,7 +316,7 @@ func (c *authController) sendAttempt(s []byte) {
317 }316 }
318 317
319 func (c *authController) sendSecret(s []byte) {318 func (c *authController) sendSecret(s []byte) {
320- c.setStatus(nefergui.LockBusy)319+ c.setStatus(lockBusy)
321 c.ph = phaseAttempt320 c.ph = phaseAttempt
322 c.arm()321 c.arm()
323 if !c.write(ports.AuthFrame{Kind: ports.AuthSecret, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {322 if !c.write(ports.AuthFrame{Kind: ports.AuthSecret, Generation: c.boot.Generation, Attempt: c.attempt, Payload: s}) {
@@ -369,7 +368,7 @@ func (c *authController) onFrame(m frameMsg) bool {
369 }368 }
370 c.ph = phasePrompt369 c.ph = phasePrompt
371 c.setPrompt(promptMore)370 c.setPrompt(promptMore)
372- c.setStatus(nefergui.LockIdle)371+ c.setStatus(lockIdle)
373 case ports.AuthStatus:372 case ports.AuthStatus:
374 // PAM text is deliberately never shown.373 // PAM text is deliberately never shown.
375 case ports.AuthResult:374 case ports.AuthResult:
@@ -385,7 +384,7 @@ func (c *authController) onFrame(m frameMsg) bool {
385 return true384 return true
386 }385 }
387 c.ph = phaseReady386 c.ph = phaseReady
388- c.setStatus(nefergui.LockFailed)387+ c.setStatus(lockFailed)
389 default:388 default:
390 c.workerLost(true)389 c.workerLost(true)
391 }390 }
M internal/adapters/visual/auth_test.go +5 -6
@@ -9,7 +9,6 @@ import (
99
1010 "github.com/bnema/neferafk/internal/adapters/auth"
1111 "github.com/bnema/neferafk/internal/ports"
12- "github.com/bnema/nefergui"
1312 "github.com/bnema/zerowrap"
1413 "github.com/stretchr/testify/mock"
1514 "github.com/stretchr/testify/require"
@@ -124,7 +123,7 @@ func TestDeniedStaysLockedAndAllowsRetry(t *testing.T) {
124123 c.submit([]byte("bad"))
125124 f := w.attempt(t)
126125 w.result(t, f.Attempt, ports.AuthDenied)
127- for recv(t, c.status) != nefergui.LockFailed {
126+ for recv(t, c.status) != lockFailed {
128127 }
129128 select {
130129 case <-c.unlock:
@@ -148,7 +147,7 @@ func TestWorkerCrashStaysLockedAndRespawnsOnNextAttempt(t *testing.T) {
148147 w1.attempt(t)
149148 w1.toVisual.Close() // worker died mid-attempt
150149 recv(t, w1.killed)
151- for recv(t, c.status) != nefergui.LockFailed {
150+ for recv(t, c.status) != lockFailed {
152151 }
153152 require.False(t, c.succeeded.Load())
154153 w2 := expectWorker(t, l)
@@ -170,7 +169,7 @@ func TestWatchdogKillsHungWorkerAndStaysLocked(t *testing.T) {
170169 c.submit([]byte("x"))
171170 w.attempt(t) // no result ever comes
172171 recv(t, w.killed)
173- for recv(t, c.status) != nefergui.LockFailed {
172+ for recv(t, c.status) != lockFailed {
174173 }
175174 select {
176175 case <-c.unlock:
@@ -271,14 +270,14 @@ func TestControllerKillsWorkerOnStaleGenerationOrAttempt(t *testing.T) {
271270 w.ready(t)
272271 c.submit([]byte("first"))
273272 w.result(t, w.attempt(t).Attempt, ports.AuthDenied)
274- for recv(t, c.status) != nefergui.LockFailed {
273+ for recv(t, c.status) != lockFailed {
275274 }
276275 c.submit([]byte("second")) // attempt 2: attempt 1 is now stale
277276 f := w.attempt(t)
278277 require.EqualValues(t, 2, f.Attempt)
279278 require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt)))
280279 recv(t, w.killed)
281- for recv(t, c.status) != nefergui.LockFailed {
280+ for recv(t, c.status) != lockFailed {
282281 }
283282 select {
284283 case <-c.unlock:
@@ -9,7 +9,6 @@ import (
9 9
10 "github.com/bnema/neferafk/internal/adapters/auth"10 "github.com/bnema/neferafk/internal/adapters/auth"
11 "github.com/bnema/neferafk/internal/ports"11 "github.com/bnema/neferafk/internal/ports"
12- "github.com/bnema/nefergui"
13 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -124,7 +123,7 @@ func TestDeniedStaysLockedAndAllowsRetry(t *testing.T) {
124 c.submit([]byte("bad"))123 c.submit([]byte("bad"))
125 f := w.attempt(t)124 f := w.attempt(t)
126 w.result(t, f.Attempt, ports.AuthDenied)125 w.result(t, f.Attempt, ports.AuthDenied)
127- for recv(t, c.status) != nefergui.LockFailed {126+ for recv(t, c.status) != lockFailed {
128 }127 }
129 select {128 select {
130 case <-c.unlock:129 case <-c.unlock:
@@ -148,7 +147,7 @@ func TestWorkerCrashStaysLockedAndRespawnsOnNextAttempt(t *testing.T) {
148 w1.attempt(t)147 w1.attempt(t)
149 w1.toVisual.Close() // worker died mid-attempt148 w1.toVisual.Close() // worker died mid-attempt
150 recv(t, w1.killed)149 recv(t, w1.killed)
151- for recv(t, c.status) != nefergui.LockFailed {150+ for recv(t, c.status) != lockFailed {
152 }151 }
153 require.False(t, c.succeeded.Load())152 require.False(t, c.succeeded.Load())
154 w2 := expectWorker(t, l)153 w2 := expectWorker(t, l)
@@ -170,7 +169,7 @@ func TestWatchdogKillsHungWorkerAndStaysLocked(t *testing.T) {
170 c.submit([]byte("x"))169 c.submit([]byte("x"))
171 w.attempt(t) // no result ever comes170 w.attempt(t) // no result ever comes
172 recv(t, w.killed)171 recv(t, w.killed)
173- for recv(t, c.status) != nefergui.LockFailed {172+ for recv(t, c.status) != lockFailed {
174 }173 }
175 select {174 select {
176 case <-c.unlock:175 case <-c.unlock:
@@ -271,14 +270,14 @@ func TestControllerKillsWorkerOnStaleGenerationOrAttempt(t *testing.T) {
271 w.ready(t)270 w.ready(t)
272 c.submit([]byte("first"))271 c.submit([]byte("first"))
273 w.result(t, w.attempt(t).Attempt, ports.AuthDenied)272 w.result(t, w.attempt(t).Attempt, ports.AuthDenied)
274- for recv(t, c.status) != nefergui.LockFailed {273+ for recv(t, c.status) != lockFailed {
275 }274 }
276 c.submit([]byte("second")) // attempt 2: attempt 1 is now stale275 c.submit([]byte("second")) // attempt 2: attempt 1 is now stale
277 f := w.attempt(t)276 f := w.attempt(t)
278 require.EqualValues(t, 2, f.Attempt)277 require.EqualValues(t, 2, f.Attempt)
279 require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt)))278 require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt)))
280 recv(t, w.killed)279 recv(t, w.killed)
281- for recv(t, c.status) != nefergui.LockFailed {280+ for recv(t, c.status) != lockFailed {
282 }281 }
283 select {282 select {
284 case <-c.unlock:283 case <-c.unlock:
M internal/adapters/visual/fade.go +140 -42
@@ -2,11 +2,14 @@ package visual
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "sync/atomic"
78 "time"
89
10+ "github.com/bnema/neferclient"
911 "github.com/bnema/nefergui"
12+ "github.com/bnema/zerowrap"
1013 )
1114
1215 // fadeTick is the redraw cadence while the fade animates.
@@ -19,8 +22,8 @@ type fadeCmd struct {
1922 }
2023
2124 // fadeModel is the overlay's animation state, shared by the surfaces of every
22-// output. The view (NeferGUI owner loop) owns the fields below "owned"; other
23-// goroutines only use send and animatingAt.
25+// output. The RunFade owner goroutine (through fadeView) owns the fields below
26+// "owned"; other goroutines only use send and animatingAt.
2427 type fadeModel struct {
2528 cmds chan fadeCmd
2629 wake chan struct{}
@@ -34,7 +37,7 @@ type fadeModel struct {
3437 // the loop starts.
3538 finish context.CancelFunc
3639
37- // owned by the view:
40+ // owned by the RunFade goroutine:
3841 started bool
3942 from, to float64
4043 start time.Time
@@ -124,55 +127,150 @@ type fadeRunner interface {
124127 RunFade(ctx context.Context, m *fadeModel) error
125128 }
126129
127-// guiFader renders the overlay as a click-through layer-shell surface.
128-type guiFader struct{}
130+// guiFader renders the overlay as one click-through layer-shell surface per
131+// output, on its own Wayland connection.
132+type guiFader struct{ log zerowrap.Logger }
129133
130-func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {
134+func (g guiFader) RunFade(ctx context.Context, m *fadeModel) (err error) {
131135 ctx, cancel := context.WithCancel(ctx)
132136 defer cancel()
133137 m.finish = cancel
134- go func() {
135- t := time.NewTicker(fadeTick)
136- defer t.Stop()
137- was := false
138- for {
139- select {
140- case <-ctx.Done():
141- return
142- case now := <-t.C:
143- // One more poke when animation stops: even a starved ticker
144- // then wakes every surface at least once past the end.
145- animating := m.animatingAt(now)
146- if animating || was {
147- m.poke()
148- }
149- was = animating
150- }
138+ conn, err := neferclient.Connect(ctx, "")
139+ if err != nil {
140+ if ctx.Err() != nil {
141+ return nil
151142 }
152- }()
153- err := nefergui.Run(ctx, m, fadeView,
154- nefergui.Title("neferafk-fade"),
155- nefergui.Size(64, 64),
156- nefergui.Transparent(),
157- nefergui.Wake(m.wake),
158- nefergui.Layer(fadeLayer()),
159- )
160- if ctx.Err() != nil && err == context.Canceled {
143+ return fmt.Errorf("connect: %w", err)
144+ }
145+ f := &fader{m: m}
146+ f.screens = newScreens(conn)
147+ // The overlay takes no input: an event failure only leaves the fade less
148+ // smooth, so it is logged and the fade goes on.
149+ f.screens.onError = func(err error) error {
150+ g.log.Warn().Err(err).Msg("fade event failed")
161151 return nil
162152 }
163- return err
153+ defer func() { err = errors.Join(err, conn.Close(), f.closeAll()) }()
154+ for _, o := range conn.Outputs() {
155+ if err = f.cover(o); err != nil {
156+ return err
157+ }
158+ }
159+ go fadeTicker(ctx, m)
160+ var retry <-chan time.Time
161+ for {
162+ select {
163+ case <-ctx.Done():
164+ return nil // cancelled, or the reveal finished
165+ case <-conn.Wake():
166+ if err = conn.Dispatch(f); err != nil {
167+ return fmt.Errorf("dispatch: %w", err)
168+ }
169+ case <-m.wake:
170+ f.invalidateAll()
171+ case <-retry:
172+ }
173+ if f.err != nil {
174+ return f.err
175+ }
176+ pending, err := f.drawAll()
177+ if err != nil {
178+ return err
179+ }
180+ retry = nil
181+ if pending {
182+ retry = time.After(gpuRetry)
183+ }
184+ }
185+}
186+
187+type fader struct {
188+ *screens
189+ m *fadeModel
190+ unnamed bool // a surface without an output name exists
191+}
192+
193+// cover adds the overlay surface of an output.
194+func (f *fader) cover(o neferclient.Output) error {
195+ if o.Name == "" { // wl_output older than v4: the compositor picks the output
196+ if f.unnamed {
197+ return nil // a second unnamed surface would land on the same output
198+ }
199+ f.unnamed = true
200+ }
201+ surf, err := f.conn.NewLayerSurface(fadeLayer(o.Name))
202+ if err != nil {
203+ return fmt.Errorf("fade surface for output %s: %w", o.Name, err)
204+ }
205+ s := newScreen(f.conn, surf, o, true)
206+ s.view = func(fr *nefergui.Frame) { fadeView(fr, f.m) }
207+ f.add(s)
208+ return nil
209+}
210+
211+// OutputAdded covers an output that appeared during the fade.
212+func (f *fader) OutputAdded(o *neferclient.Output) {
213+ if !f.covers(o.Global) {
214+ if err := f.cover(*o); err != nil {
215+ f.fail(err)
216+ }
217+ }
218+}
219+
220+// OutputRemoved frees the overlay of an output that went away.
221+func (f *fader) OutputRemoved(global uint32) {
222+ for _, s := range f.byID {
223+ if s.output == global && s.name == "" {
224+ f.unnamed = false
225+ }
226+ }
227+ f.screens.OutputRemoved(global)
228+}
229+
230+// Closed: the compositor closed an overlay surface. An output left without
231+// one stays unfaded; the compositor chose to close it.
232+func (f *fader) Closed(id neferclient.SurfaceID) {
233+ if s := f.byID[id]; s != nil {
234+ if s.name == "" {
235+ f.unnamed = false
236+ }
237+ if err := f.forget(s); err != nil {
238+ f.fail(err)
239+ }
240+ }
241+}
242+
243+// fadeTicker requests redraws while the fade animates.
244+func fadeTicker(ctx context.Context, m *fadeModel) {
245+ t := time.NewTicker(fadeTick)
246+ defer t.Stop()
247+ was := false
248+ for {
249+ select {
250+ case <-ctx.Done():
251+ return
252+ case now := <-t.C:
253+ // One more poke when animation stops: even a starved ticker then
254+ // wakes every surface at least once past the end.
255+ animating := m.animatingAt(now)
256+ if animating || was {
257+ m.poke()
258+ }
259+ was = animating
260+ }
261+ }
164262 }
165263
166-// fadeLayer: overlay on every output, all anchors, no keyboard, fully
167-// click-through.
168-func fadeLayer() nefergui.LayerConfig {
169- return nefergui.LayerConfig{
170- AllOutputs: true,
264+// fadeLayer: overlay on one output, all anchors, no keyboard, fully
265+// click-through. An empty output name lets the compositor choose.
266+func fadeLayer(output string) neferclient.LayerConfig {
267+ return neferclient.LayerConfig{
268+ Output: output,
171269 Namespace: "neferafk-fade",
172- Level: nefergui.LayerOverlay,
173- Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,
174- Keyboard: nefergui.KeyboardNone,
270+ Level: neferclient.LayerOverlay,
271+ Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
272+ Keyboard: neferclient.KeyboardNone,
175273 ExclusiveZone: -1,
176- InputRects: []nefergui.Rect{},
274+ InputRects: []neferclient.Rect{},
177275 }
178276 }
@@ -2,11 +2,14 @@ package visual
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "sync/atomic"7 "sync/atomic"
7 "time"8 "time"
8 9
10+ "github.com/bnema/neferclient"
9 "github.com/bnema/nefergui"11 "github.com/bnema/nefergui"
12+ "github.com/bnema/zerowrap"
10 )13 )
11 14
12 // fadeTick is the redraw cadence while the fade animates.15 // fadeTick is the redraw cadence while the fade animates.
@@ -19,8 +22,8 @@ type fadeCmd struct {
19 }22 }
20 23
21 // fadeModel is the overlay's animation state, shared by the surfaces of every24 // fadeModel is the overlay's animation state, shared by the surfaces of every
22-// output. The view (NeferGUI owner loop) owns the fields below "owned"; other25+// output. The RunFade owner goroutine (through fadeView) owns the fields below
23-// goroutines only use send and animatingAt.26+// "owned"; other goroutines only use send and animatingAt.
24 type fadeModel struct {27 type fadeModel struct {
25 cmds chan fadeCmd28 cmds chan fadeCmd
26 wake chan struct{}29 wake chan struct{}
@@ -34,7 +37,7 @@ type fadeModel struct {
34 // the loop starts.37 // the loop starts.
35 finish context.CancelFunc38 finish context.CancelFunc
36 39
37- // owned by the view:40+ // owned by the RunFade goroutine:
38 started bool41 started bool
39 from, to float6442 from, to float64
40 start time.Time43 start time.Time
@@ -124,55 +127,150 @@ type fadeRunner interface {
124 RunFade(ctx context.Context, m *fadeModel) error127 RunFade(ctx context.Context, m *fadeModel) error
125 }128 }
126 129
127-// guiFader renders the overlay as a click-through layer-shell surface.130+// guiFader renders the overlay as one click-through layer-shell surface per
128-type guiFader struct{}131+// output, on its own Wayland connection.
132+type guiFader struct{ log zerowrap.Logger }
129 133
130-func (guiFader) RunFade(ctx context.Context, m *fadeModel) error {134+func (g guiFader) RunFade(ctx context.Context, m *fadeModel) (err error) {
131 ctx, cancel := context.WithCancel(ctx)135 ctx, cancel := context.WithCancel(ctx)
132 defer cancel()136 defer cancel()
133 m.finish = cancel137 m.finish = cancel
134- go func() {138+ conn, err := neferclient.Connect(ctx, "")
135- t := time.NewTicker(fadeTick)139+ if err != nil {
136- defer t.Stop()140+ if ctx.Err() != nil {
137- was := false141+ return nil
138- for {
139- select {
140- case <-ctx.Done():
141- return
142- case now := <-t.C:
143- // One more poke when animation stops: even a starved ticker
144- // then wakes every surface at least once past the end.
145- animating := m.animatingAt(now)
146- if animating || was {
147- m.poke()
148- }
149- was = animating
150- }
151 }142 }
152- }()143+ return fmt.Errorf("connect: %w", err)
153- err := nefergui.Run(ctx, m, fadeView,144+ }
154- nefergui.Title("neferafk-fade"),145+ f := &fader{m: m}
155- nefergui.Size(64, 64),146+ f.screens = newScreens(conn)
156- nefergui.Transparent(),147+ // The overlay takes no input: an event failure only leaves the fade less
157- nefergui.Wake(m.wake),148+ // smooth, so it is logged and the fade goes on.
158- nefergui.Layer(fadeLayer()),149+ f.screens.onError = func(err error) error {
159- )150+ g.log.Warn().Err(err).Msg("fade event failed")
160- if ctx.Err() != nil && err == context.Canceled {
161 return nil151 return nil
162 }152 }
163- return err153+ defer func() { err = errors.Join(err, conn.Close(), f.closeAll()) }()
154+ for _, o := range conn.Outputs() {
155+ if err = f.cover(o); err != nil {
156+ return err
157+ }
158+ }
159+ go fadeTicker(ctx, m)
160+ var retry <-chan time.Time
161+ for {
162+ select {
163+ case <-ctx.Done():
164+ return nil // cancelled, or the reveal finished
165+ case <-conn.Wake():
166+ if err = conn.Dispatch(f); err != nil {
167+ return fmt.Errorf("dispatch: %w", err)
168+ }
169+ case <-m.wake:
170+ f.invalidateAll()
171+ case <-retry:
172+ }
173+ if f.err != nil {
174+ return f.err
175+ }
176+ pending, err := f.drawAll()
177+ if err != nil {
178+ return err
179+ }
180+ retry = nil
181+ if pending {
182+ retry = time.After(gpuRetry)
183+ }
184+ }
185+}
186+
187+type fader struct {
188+ *screens
189+ m *fadeModel
190+ unnamed bool // a surface without an output name exists
191+}
192+
193+// cover adds the overlay surface of an output.
194+func (f *fader) cover(o neferclient.Output) error {
195+ if o.Name == "" { // wl_output older than v4: the compositor picks the output
196+ if f.unnamed {
197+ return nil // a second unnamed surface would land on the same output
198+ }
199+ f.unnamed = true
200+ }
201+ surf, err := f.conn.NewLayerSurface(fadeLayer(o.Name))
202+ if err != nil {
203+ return fmt.Errorf("fade surface for output %s: %w", o.Name, err)
204+ }
205+ s := newScreen(f.conn, surf, o, true)
206+ s.view = func(fr *nefergui.Frame) { fadeView(fr, f.m) }
207+ f.add(s)
208+ return nil
209+}
210+
211+// OutputAdded covers an output that appeared during the fade.
212+func (f *fader) OutputAdded(o *neferclient.Output) {
213+ if !f.covers(o.Global) {
214+ if err := f.cover(*o); err != nil {
215+ f.fail(err)
216+ }
217+ }
218+}
219+
220+// OutputRemoved frees the overlay of an output that went away.
221+func (f *fader) OutputRemoved(global uint32) {
222+ for _, s := range f.byID {
223+ if s.output == global && s.name == "" {
224+ f.unnamed = false
225+ }
226+ }
227+ f.screens.OutputRemoved(global)
228+}
229+
230+// Closed: the compositor closed an overlay surface. An output left without
231+// one stays unfaded; the compositor chose to close it.
232+func (f *fader) Closed(id neferclient.SurfaceID) {
233+ if s := f.byID[id]; s != nil {
234+ if s.name == "" {
235+ f.unnamed = false
236+ }
237+ if err := f.forget(s); err != nil {
238+ f.fail(err)
239+ }
240+ }
241+}
242+
243+// fadeTicker requests redraws while the fade animates.
244+func fadeTicker(ctx context.Context, m *fadeModel) {
245+ t := time.NewTicker(fadeTick)
246+ defer t.Stop()
247+ was := false
248+ for {
249+ select {
250+ case <-ctx.Done():
251+ return
252+ case now := <-t.C:
253+ // One more poke when animation stops: even a starved ticker then
254+ // wakes every surface at least once past the end.
255+ animating := m.animatingAt(now)
256+ if animating || was {
257+ m.poke()
258+ }
259+ was = animating
260+ }
261+ }
164 }262 }
165 263
166-// fadeLayer: overlay on every output, all anchors, no keyboard, fully264+// fadeLayer: overlay on one output, all anchors, no keyboard, fully
167-// click-through.265+// click-through. An empty output name lets the compositor choose.
168-func fadeLayer() nefergui.LayerConfig {266+func fadeLayer(output string) neferclient.LayerConfig {
169- return nefergui.LayerConfig{267+ return neferclient.LayerConfig{
170- AllOutputs: true,268+ Output: output,
171 Namespace: "neferafk-fade",269 Namespace: "neferafk-fade",
172- Level: nefergui.LayerOverlay,270+ Level: neferclient.LayerOverlay,
173- Anchors: nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight,271+ Anchors: neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight,
174- Keyboard: nefergui.KeyboardNone,272+ Keyboard: neferclient.KeyboardNone,
175 ExclusiveZone: -1,273 ExclusiveZone: -1,
176- InputRects: []nefergui.Rect{},274+ InputRects: []neferclient.Rect{},
177 }275 }
178 }276 }
A internal/adapters/visual/guilock.go +344 -0
@@ -0,0 +1,344 @@
1+package visual
2+
3+import (
4+ "cmp"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "slices"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/neferafk/internal/ports"
13+ "github.com/bnema/neferclient"
14+ "github.com/bnema/nefergui"
15+)
16+
17+// errLockFinished reports that the compositor refused the session lock or
18+// ended it (ext_session_lock_v1.finished). The lock never unlocks in response.
19+var errLockFinished = errors.New("session lock finished by the compositor")
20+
21+// lockStatus is the authentication state shown under the input.
22+type lockStatus uint8
23+
24+const (
25+ lockIdle lockStatus = iota // ready for input
26+ lockBusy // an attempt is being verified
27+ lockFailed // the last attempt was rejected
28+)
29+
30+// lockState is what the lock view may know: never the secret itself.
31+type lockState struct {
32+ Mask int // typed code points, for a mask of that many dots
33+ Status lockStatus // latest value received on lockConfig.Status
34+}
35+
36+// lockConfig wires the session lock to its caller. Every callback runs on the
37+// lock's owner goroutine.
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.
42+ View func(*nefergui.Frame, lockState)
43+ // OnLocked runs once when the compositor confirms the lock.
44+ OnLocked func()
45+ // OnOutputError reports an output added after the lock was acquired that
46+ // could not be covered; the compositor keeps it black.
47+ OnOutputError func(output string, err error)
48+ // OnError reports an event failure that does not end the lock (cursor,
49+ // key repeat, one bad format entry...). Keyboard setup failures end the
50+ // run instead: the caller starts a new lock connection while the session
51+ // stays locked.
52+ OnError func(err error)
53+ // OnSubmit runs when Enter is pressed after locked with a non-empty
54+ // secret. It must not block. secret is wiped right after it returns.
55+ OnSubmit func(secret []byte)
56+ // Unlock, when it receives a value, unlocks the session once locked. It
57+ // is the only unlock path. Closing it is not a request.
58+ Unlock <-chan struct{}
59+ // Status delivers the state shown as lockState.Status.
60+ Status <-chan lockStatus
61+ // Wake requests a redraw.
62+ Wake <-chan struct{}
63+}
64+
65+// Keys handled outside the secret buffer (xkb keysyms).
66+const (
67+ keysymReturn = 0xff0d
68+ keysymKPEnter = 0xff8d
69+ keysymEscape = 0xff1b
70+ keysymU = 0x75
71+ keysymUpperU = 0x55 // with Shift or Caps Lock
72+)
73+
74+// lockSecretMax bounds the typed secret in code points. The buffer drops a
75+// key whose text would pass its capacity; the margin over a frame (one key
76+// yields at most a few code points) means a secret that fits a frame never
77+// lost a key. Enter refuses a secret whose UTF-8 bytes do not fit a frame.
78+const lockSecretMax = ports.AuthMaxSecret + utf8.UTFMax
79+
80+// guiLocker runs ext-session-lock on its own Wayland connection with
81+// neferclient and draws with one NeferGUI renderer per output.
82+type guiLocker struct{}
83+
84+// RunLock covers every output until an unlock requested on cfg.Unlock and
85+// returns nil. It returns errLockFinished (wrapped) when the compositor
86+// refuses or ends the lock, or the first failure. It never unlocks on failure
87+// or cancellation: closing the connection keeps the session locked.
88+func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) {
89+ if cfg.View == nil {
90+ return errors.New("lock needs a view")
91+ }
92+ conn, err := neferclient.Connect(ctx, "")
93+ if err != nil {
94+ return fmt.Errorf("connect: %w", err)
95+ }
96+ l := &locker{cfg: cfg, secret: neferclient.NewSecretBuffer(lockSecretMax), initial: map[uint32]bool{}}
97+ l.screens = newScreens(conn)
98+ l.screens.onSetup = l.setupPolicy
99+ l.screens.onError = l.errorPolicy
100+ defer func() {
101+ l.secret.Wipe()
102+ err = errors.Join(err, conn.Close(), l.screens.closeAll())
103+ }()
104+ // Lock at once, even with no output yet: outputs that appear later are
105+ // covered by OutputAdded, and the session is locked meanwhile.
106+ if l.lock, err = conn.Lock(); err != nil {
107+ return fmt.Errorf("lock: %w", err)
108+ }
109+ outputs := conn.Outputs()
110+ slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })
111+ for _, o := range outputs {
112+ if err = l.cover(o); err != nil {
113+ return err
114+ }
115+ l.initial[o.Global] = true
116+ }
117+ l.seat = conn.Seat()
118+ l.seat.SetSecret(l.secret)
119+ defer l.seat.SetSecret(nil)
120+ var retry <-chan time.Time
121+ for {
122+ if err = l.wait(ctx, retry); err != nil {
123+ return err
124+ }
125+ if l.unlock && l.lock.Locked() {
126+ l.secret.Wipe()
127+ return l.lock.Unlock() // roundtrips: the compositor processed it
128+ }
129+ pending, err := l.screens.drawAll()
130+ if err != nil {
131+ return err
132+ }
133+ retry = nil
134+ if pending {
135+ retry = time.After(gpuRetry)
136+ }
137+ }
138+}
139+
140+type locker struct {
141+ *screens
142+
143+ cfg lockConfig
144+ lock *neferclient.Lock
145+ seat *neferclient.Seat
146+ secret *neferclient.SecretBuffer
147+ host *screen
148+ initial map[uint32]bool // outputs present at acquisition: their failures abort
149+ status lockStatus
150+ unlock bool // requested; honoured once locked
151+}
152+
153+// wait blocks for one event and applies it.
154+func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error {
155+ select {
156+ case <-ctx.Done():
157+ return ctx.Err()
158+ case <-l.conn.Wake():
159+ if err := l.conn.Dispatch(l); err != nil {
160+ return fmt.Errorf("dispatch: %w", err)
161+ }
162+ case _, ok := <-l.cfg.Unlock:
163+ if ok {
164+ l.unlock = true
165+ } else {
166+ l.cfg.Unlock = nil // closing is not a request
167+ }
168+ case st, ok := <-l.cfg.Status:
169+ if ok {
170+ l.status = st
171+ l.invalidateHost()
172+ } else {
173+ l.cfg.Status = nil
174+ }
175+ case <-l.cfg.Wake:
176+ l.invalidateHost()
177+ case <-retry:
178+ }
179+ return l.err
180+}
181+
182+// cover creates the lock surface of an output.
183+func (l *locker) cover(o neferclient.Output) error {
184+ surf, err := l.lock.NewSurface(o.Global)
185+ if err != nil {
186+ return fmt.Errorf("lock surface for output %s: %w", o.Name, err)
187+ }
188+ s := newScreen(l.conn, surf, o, false)
189+ s.view = func(f *nefergui.Frame) { l.view(f, s) }
190+ l.add(s)
191+ if l.host == nil { // outputs are covered in ascending order: the lowest hosts
192+ l.setHost(s)
193+ }
194+ return nil
195+}
196+
197+// view shows the lock content on the host output and plain black elsewhere.
198+func (l *locker) view(f *nefergui.Frame, s *screen) {
199+ if s != l.host {
200+ f.Root(nefergui.Inline(cssBlack))
201+ return
202+ }
203+ l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})
204+}
205+
206+const cssBlack = "width:100%;height:100%;background-color:#000000"
207+
208+func (l *locker) setHost(s *screen) {
209+ if l.host == s {
210+ return
211+ }
212+ if l.host != nil {
213+ l.host.invalidate()
214+ }
215+ l.host = s
216+ s.invalidate()
217+}
218+
219+func (l *locker) invalidateHost() {
220+ if l.host != nil {
221+ l.host.invalidate()
222+ }
223+}
224+
225+// setupPolicy: the first renderer failure on an output present at acquisition
226+// aborts the lock. A failure on a hotplugged output, or a rebuild after a
227+// feedback change, is reported and that output dropped (the compositor keeps
228+// it black); the lock stays.
229+func (l *locker) setupPolicy(s *screen, rebuild bool, err error) error {
230+ err = fmt.Errorf("lock render path for output %s: %w", s.name, err)
231+ if l.initial[s.output] && !rebuild {
232+ return err
233+ }
234+ if l.cfg.OnOutputError != nil {
235+ l.cfg.OnOutputError(s.name, err)
236+ }
237+ return nil
238+}
239+
240+// errorPolicy ends the run when the keyboard cannot work, as nobody could
241+// type the secret; other event failures are reported and the lock goes on.
242+func (l *locker) errorPolicy(err error) error {
243+ if keyboardError(err) {
244+ return err
245+ }
246+ if l.cfg.OnError != nil {
247+ l.cfg.OnError(err)
248+ }
249+ return nil
250+}
251+
252+// forgetHost picks a new host when the host screen goes away.
253+func (l *locker) forgetHost() {
254+ if l.host == nil || l.byID[l.host.surf.ID()] == l.host {
255+ return
256+ }
257+ l.host = nil
258+ for _, s := range l.byID {
259+ if l.host == nil || s.output < l.host.output {
260+ l.host = s
261+ }
262+ }
263+ if l.host != nil {
264+ l.host.invalidate()
265+ }
266+}
267+
268+func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {
269+ l.screens.Configure(id, w, h)
270+ l.forgetHost()
271+}
272+
273+func (l *locker) FeedbackDone(id neferclient.SurfaceID) {
274+ l.screens.FeedbackDone(id)
275+ l.forgetHost()
276+}
277+
278+func (l *locker) OutputRemoved(global uint32) {
279+ l.screens.OutputRemoved(global)
280+ delete(l.initial, global)
281+ l.forgetHost()
282+}
283+
284+// OutputAdded covers an output that appeared after the lock was requested.
285+func (l *locker) OutputAdded(o *neferclient.Output) {
286+ if l.lock == nil || l.err != nil || l.covers(o.Global) {
287+ return
288+ }
289+ if err := l.cover(*o); err != nil && l.cfg.OnOutputError != nil {
290+ l.cfg.OnOutputError(o.Name, err)
291+ }
292+}
293+
294+// Locked confirms the lock, unless the run already failed in the same batch
295+// of events: it is about to return that error.
296+func (l *locker) Locked() {
297+ if l.err != nil {
298+ return
299+ }
300+ l.invalidateHost()
301+ if l.cfg.OnLocked != nil {
302+ l.cfg.OnLocked()
303+ }
304+}
305+
306+func (l *locker) LockFinished() {
307+ l.fail(errors.Join(errLockFinished, l.lock.Close()))
308+}
309+
310+func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
311+ if s := l.byID[id]; s != nil && focused {
312+ l.setHost(s)
313+ }
314+}
315+
316+func (l *locker) SecretChanged(int) { l.invalidateHost() }
317+
318+// Key handles the keys that are not secret text: Enter submits, Escape and
319+// Ctrl+U clear. Before locked, Enter only clears.
320+func (l *locker) Key(ev *neferclient.KeyEvent) {
321+ if ev.Secret || !ev.Pressed || ev.Repeat || l.lock == nil {
322+ return
323+ }
324+ switch {
325+ case ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter:
326+ defer l.clearSecret() // even if OnSubmit panics
327+ switch {
328+ case !l.lock.Locked() || l.cfg.OnSubmit == nil || l.secret.Len() == 0:
329+ case len(l.secret.Bytes()) > ports.AuthMaxSecret:
330+ if l.status != lockBusy { // an attempt still running keeps its status
331+ l.status = lockFailed // too long to verify: refused like a wrong secret
332+ }
333+ default:
334+ l.cfg.OnSubmit(l.secret.Bytes())
335+ }
336+ case ev.Keysym == keysymEscape, (ev.Keysym == keysymU || ev.Keysym == keysymUpperU) && ev.Modifiers&neferclient.ModCtrl != 0:
337+ l.clearSecret()
338+ }
339+}
340+
341+func (l *locker) clearSecret() {
342+ l.secret.Wipe()
343+ l.invalidateHost()
344+}
@@ -0,0 +1,344 @@
1+package visual
2+
3+import (
4+ "cmp"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "slices"
9+ "time"
10+ "unicode/utf8"
11+
12+ "github.com/bnema/neferafk/internal/ports"
13+ "github.com/bnema/neferclient"
14+ "github.com/bnema/nefergui"
15+)
16+
17+// errLockFinished reports that the compositor refused the session lock or
18+// ended it (ext_session_lock_v1.finished). The lock never unlocks in response.
19+var errLockFinished = errors.New("session lock finished by the compositor")
20+
21+// lockStatus is the authentication state shown under the input.
22+type lockStatus uint8
23+
24+const (
25+ lockIdle lockStatus = iota // ready for input
26+ lockBusy // an attempt is being verified
27+ lockFailed // the last attempt was rejected
28+)
29+
30+// lockState is what the lock view may know: never the secret itself.
31+type lockState struct {
32+ Mask int // typed code points, for a mask of that many dots
33+ Status lockStatus // latest value received on lockConfig.Status
34+}
35+
36+// lockConfig wires the session lock to its caller. Every callback runs on the
37+// lock's owner goroutine.
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.
42+ View func(*nefergui.Frame, lockState)
43+ // OnLocked runs once when the compositor confirms the lock.
44+ OnLocked func()
45+ // OnOutputError reports an output added after the lock was acquired that
46+ // could not be covered; the compositor keeps it black.
47+ OnOutputError func(output string, err error)
48+ // OnError reports an event failure that does not end the lock (cursor,
49+ // key repeat, one bad format entry...). Keyboard setup failures end the
50+ // run instead: the caller starts a new lock connection while the session
51+ // stays locked.
52+ OnError func(err error)
53+ // OnSubmit runs when Enter is pressed after locked with a non-empty
54+ // secret. It must not block. secret is wiped right after it returns.
55+ OnSubmit func(secret []byte)
56+ // Unlock, when it receives a value, unlocks the session once locked. It
57+ // is the only unlock path. Closing it is not a request.
58+ Unlock <-chan struct{}
59+ // Status delivers the state shown as lockState.Status.
60+ Status <-chan lockStatus
61+ // Wake requests a redraw.
62+ Wake <-chan struct{}
63+}
64+
65+// Keys handled outside the secret buffer (xkb keysyms).
66+const (
67+ keysymReturn = 0xff0d
68+ keysymKPEnter = 0xff8d
69+ keysymEscape = 0xff1b
70+ keysymU = 0x75
71+ keysymUpperU = 0x55 // with Shift or Caps Lock
72+)
73+
74+// lockSecretMax bounds the typed secret in code points. The buffer drops a
75+// key whose text would pass its capacity; the margin over a frame (one key
76+// yields at most a few code points) means a secret that fits a frame never
77+// lost a key. Enter refuses a secret whose UTF-8 bytes do not fit a frame.
78+const lockSecretMax = ports.AuthMaxSecret + utf8.UTFMax
79+
80+// guiLocker runs ext-session-lock on its own Wayland connection with
81+// neferclient and draws with one NeferGUI renderer per output.
82+type guiLocker struct{}
83+
84+// RunLock covers every output until an unlock requested on cfg.Unlock and
85+// returns nil. It returns errLockFinished (wrapped) when the compositor
86+// refuses or ends the lock, or the first failure. It never unlocks on failure
87+// or cancellation: closing the connection keeps the session locked.
88+func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) {
89+ if cfg.View == nil {
90+ return errors.New("lock needs a view")
91+ }
92+ conn, err := neferclient.Connect(ctx, "")
93+ if err != nil {
94+ return fmt.Errorf("connect: %w", err)
95+ }
96+ l := &locker{cfg: cfg, secret: neferclient.NewSecretBuffer(lockSecretMax), initial: map[uint32]bool{}}
97+ l.screens = newScreens(conn)
98+ l.screens.onSetup = l.setupPolicy
99+ l.screens.onError = l.errorPolicy
100+ defer func() {
101+ l.secret.Wipe()
102+ err = errors.Join(err, conn.Close(), l.screens.closeAll())
103+ }()
104+ // Lock at once, even with no output yet: outputs that appear later are
105+ // covered by OutputAdded, and the session is locked meanwhile.
106+ if l.lock, err = conn.Lock(); err != nil {
107+ return fmt.Errorf("lock: %w", err)
108+ }
109+ outputs := conn.Outputs()
110+ slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })
111+ for _, o := range outputs {
112+ if err = l.cover(o); err != nil {
113+ return err
114+ }
115+ l.initial[o.Global] = true
116+ }
117+ l.seat = conn.Seat()
118+ l.seat.SetSecret(l.secret)
119+ defer l.seat.SetSecret(nil)
120+ var retry <-chan time.Time
121+ for {
122+ if err = l.wait(ctx, retry); err != nil {
123+ return err
124+ }
125+ if l.unlock && l.lock.Locked() {
126+ l.secret.Wipe()
127+ return l.lock.Unlock() // roundtrips: the compositor processed it
128+ }
129+ pending, err := l.screens.drawAll()
130+ if err != nil {
131+ return err
132+ }
133+ retry = nil
134+ if pending {
135+ retry = time.After(gpuRetry)
136+ }
137+ }
138+}
139+
140+type locker struct {
141+ *screens
142+
143+ cfg lockConfig
144+ lock *neferclient.Lock
145+ seat *neferclient.Seat
146+ secret *neferclient.SecretBuffer
147+ host *screen
148+ initial map[uint32]bool // outputs present at acquisition: their failures abort
149+ status lockStatus
150+ unlock bool // requested; honoured once locked
151+}
152+
153+// wait blocks for one event and applies it.
154+func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error {
155+ select {
156+ case <-ctx.Done():
157+ return ctx.Err()
158+ case <-l.conn.Wake():
159+ if err := l.conn.Dispatch(l); err != nil {
160+ return fmt.Errorf("dispatch: %w", err)
161+ }
162+ case _, ok := <-l.cfg.Unlock:
163+ if ok {
164+ l.unlock = true
165+ } else {
166+ l.cfg.Unlock = nil // closing is not a request
167+ }
168+ case st, ok := <-l.cfg.Status:
169+ if ok {
170+ l.status = st
171+ l.invalidateHost()
172+ } else {
173+ l.cfg.Status = nil
174+ }
175+ case <-l.cfg.Wake:
176+ l.invalidateHost()
177+ case <-retry:
178+ }
179+ return l.err
180+}
181+
182+// cover creates the lock surface of an output.
183+func (l *locker) cover(o neferclient.Output) error {
184+ surf, err := l.lock.NewSurface(o.Global)
185+ if err != nil {
186+ return fmt.Errorf("lock surface for output %s: %w", o.Name, err)
187+ }
188+ s := newScreen(l.conn, surf, o, false)
189+ s.view = func(f *nefergui.Frame) { l.view(f, s) }
190+ l.add(s)
191+ if l.host == nil { // outputs are covered in ascending order: the lowest hosts
192+ l.setHost(s)
193+ }
194+ return nil
195+}
196+
197+// view shows the lock content on the host output and plain black elsewhere.
198+func (l *locker) view(f *nefergui.Frame, s *screen) {
199+ if s != l.host {
200+ f.Root(nefergui.Inline(cssBlack))
201+ return
202+ }
203+ l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})
204+}
205+
206+const cssBlack = "width:100%;height:100%;background-color:#000000"
207+
208+func (l *locker) setHost(s *screen) {
209+ if l.host == s {
210+ return
211+ }
212+ if l.host != nil {
213+ l.host.invalidate()
214+ }
215+ l.host = s
216+ s.invalidate()
217+}
218+
219+func (l *locker) invalidateHost() {
220+ if l.host != nil {
221+ l.host.invalidate()
222+ }
223+}
224+
225+// setupPolicy: the first renderer failure on an output present at acquisition
226+// aborts the lock. A failure on a hotplugged output, or a rebuild after a
227+// feedback change, is reported and that output dropped (the compositor keeps
228+// it black); the lock stays.
229+func (l *locker) setupPolicy(s *screen, rebuild bool, err error) error {
230+ err = fmt.Errorf("lock render path for output %s: %w", s.name, err)
231+ if l.initial[s.output] && !rebuild {
232+ return err
233+ }
234+ if l.cfg.OnOutputError != nil {
235+ l.cfg.OnOutputError(s.name, err)
236+ }
237+ return nil
238+}
239+
240+// errorPolicy ends the run when the keyboard cannot work, as nobody could
241+// type the secret; other event failures are reported and the lock goes on.
242+func (l *locker) errorPolicy(err error) error {
243+ if keyboardError(err) {
244+ return err
245+ }
246+ if l.cfg.OnError != nil {
247+ l.cfg.OnError(err)
248+ }
249+ return nil
250+}
251+
252+// forgetHost picks a new host when the host screen goes away.
253+func (l *locker) forgetHost() {
254+ if l.host == nil || l.byID[l.host.surf.ID()] == l.host {
255+ return
256+ }
257+ l.host = nil
258+ for _, s := range l.byID {
259+ if l.host == nil || s.output < l.host.output {
260+ l.host = s
261+ }
262+ }
263+ if l.host != nil {
264+ l.host.invalidate()
265+ }
266+}
267+
268+func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {
269+ l.screens.Configure(id, w, h)
270+ l.forgetHost()
271+}
272+
273+func (l *locker) FeedbackDone(id neferclient.SurfaceID) {
274+ l.screens.FeedbackDone(id)
275+ l.forgetHost()
276+}
277+
278+func (l *locker) OutputRemoved(global uint32) {
279+ l.screens.OutputRemoved(global)
280+ delete(l.initial, global)
281+ l.forgetHost()
282+}
283+
284+// OutputAdded covers an output that appeared after the lock was requested.
285+func (l *locker) OutputAdded(o *neferclient.Output) {
286+ if l.lock == nil || l.err != nil || l.covers(o.Global) {
287+ return
288+ }
289+ if err := l.cover(*o); err != nil && l.cfg.OnOutputError != nil {
290+ l.cfg.OnOutputError(o.Name, err)
291+ }
292+}
293+
294+// Locked confirms the lock, unless the run already failed in the same batch
295+// of events: it is about to return that error.
296+func (l *locker) Locked() {
297+ if l.err != nil {
298+ return
299+ }
300+ l.invalidateHost()
301+ if l.cfg.OnLocked != nil {
302+ l.cfg.OnLocked()
303+ }
304+}
305+
306+func (l *locker) LockFinished() {
307+ l.fail(errors.Join(errLockFinished, l.lock.Close()))
308+}
309+
310+func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
311+ if s := l.byID[id]; s != nil && focused {
312+ l.setHost(s)
313+ }
314+}
315+
316+func (l *locker) SecretChanged(int) { l.invalidateHost() }
317+
318+// Key handles the keys that are not secret text: Enter submits, Escape and
319+// Ctrl+U clear. Before locked, Enter only clears.
320+func (l *locker) Key(ev *neferclient.KeyEvent) {
321+ if ev.Secret || !ev.Pressed || ev.Repeat || l.lock == nil {
322+ return
323+ }
324+ switch {
325+ case ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter:
326+ defer l.clearSecret() // even if OnSubmit panics
327+ switch {
328+ case !l.lock.Locked() || l.cfg.OnSubmit == nil || l.secret.Len() == 0:
329+ case len(l.secret.Bytes()) > ports.AuthMaxSecret:
330+ if l.status != lockBusy { // an attempt still running keeps its status
331+ l.status = lockFailed // too long to verify: refused like a wrong secret
332+ }
333+ default:
334+ l.cfg.OnSubmit(l.secret.Bytes())
335+ }
336+ case ev.Keysym == keysymEscape, (ev.Keysym == keysymU || ev.Keysym == keysymUpperU) && ev.Modifiers&neferclient.ModCtrl != 0:
337+ l.clearSecret()
338+ }
339+}
340+
341+func (l *locker) clearSecret() {
342+ l.secret.Wipe()
343+ l.invalidateHost()
344+}
A internal/adapters/visual/headless_test.go +172 -0
@@ -0,0 +1,172 @@
1+package visual
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "syscall"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferafk/internal/ports"
15+ "github.com/bnema/nefergui"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// The headless tests run the real lock and fade loops against a NeferWL
20+// compositor started with two virtual outputs in an isolated runtime
21+// directory. They are skipped unless NEFERAFK_HEADLESS names its binary, and
22+// they only ever connect to that compositor: WAYLAND_DISPLAY is set to its
23+// absolute socket path. Never point them at a real session.
24+
25+var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
26+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
27+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
28+
29+// startHeadless runs NeferWL, points WAYLAND_DISPLAY at it for this test and
30+// returns its input script writer.
31+func startHeadless(t *testing.T) io.Writer {
32+ t.Helper()
33+ bin := os.Getenv("NEFERAFK_HEADLESS")
34+ if bin == "" {
35+ t.Skip("set NEFERAFK_HEADLESS to a NeferWL binary")
36+ }
37+ bin, err := filepath.Abs(bin)
38+ require.NoError(t, err)
39+ root := t.TempDir()
40+ env := []string{}
41+ for _, e := range os.Environ() {
42+ keep := true
43+ for _, p := range headlessEnvDrop {
44+ keep = keep && !strings.HasPrefix(e, p)
45+ }
46+ if keep {
47+ env = append(env, e)
48+ }
49+ }
50+ run := filepath.Join(root, "run")
51+ for _, name := range []string{"run", "config", "data", "state"} {
52+ require.NoError(t, os.Mkdir(filepath.Join(root, name), 0o700))
53+ env = append(env, "XDG_"+map[string]string{"run": "RUNTIME_DIR", "config": "CONFIG_HOME", "data": "DATA_HOME", "state": "STATE_HOME"}[name]+"="+filepath.Join(root, name))
54+ }
55+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
56+ require.NoError(t, err)
57+ t.Cleanup(func() { _ = logf.Close() })
58+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480,320x240",
59+ "--timeout", "60s", "--input", "-")
60+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
61+ cmd.Env, cmd.Stdout, cmd.Stderr = env, logf, logf
62+ stdin, err := cmd.StdinPipe()
63+ require.NoError(t, err)
64+ require.NoError(t, cmd.Start())
65+ exited := make(chan struct{})
66+ go func() { _ = cmd.Wait(); close(exited) }()
67+ t.Cleanup(func() {
68+ _ = stdin.Close()
69+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
70+ <-exited
71+ })
72+ for deadline := time.Now().Add(15 * time.Second); ; {
73+ paths, _ := filepath.Glob(filepath.Join(run, "wayland-*"))
74+ for _, p := range paths {
75+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
76+ require.True(t, filepath.IsAbs(p))
77+ t.Setenv("WAYLAND_DISPLAY", p) // absolute: XDG_RUNTIME_DIR is not used
78+ return stdin
79+ }
80+ }
81+ select {
82+ case <-exited:
83+ b, _ := os.ReadFile(logf.Name())
84+ t.Fatalf("neferwl exited before serving:\n%s", b)
85+ case <-time.After(5 * time.Millisecond):
86+ }
87+ if time.Now().After(deadline) {
88+ b, _ := os.ReadFile(logf.Name())
89+ t.Fatalf("headless socket timeout:\n%s", b)
90+ }
91+ }
92+}
93+
94+// TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest covers both outputs,
95+// waits for locked and types into the lock. Escape and Ctrl+Shift+U clear the
96+// field, a secret too long for an authentication frame is never submitted,
97+// Enter submits exactly the typed bytes, and only the Unlock request ends the
98+// lock.
99+func TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest(t *testing.T) {
100+ input := startHeadless(t)
101+ locked := make(chan struct{}, 1)
102+ submits := make(chan []byte, 4)
103+ outputErrs := make(chan string, 4)
104+ unlock := make(chan struct{}, 1)
105+ status := make(chan lockStatus, 1)
106+ cfg := lockConfig{
107+ View: func(f *nefergui.Frame, st lockState) { lockView(f, st, promptPassword, time.Now()) },
108+ OnLocked: func() { locked <- struct{}{} },
109+ OnSubmit: func(secret []byte) { submits <- append([]byte(nil), secret...) },
110+ OnOutputError: func(output string, _ error) { outputErrs <- output },
111+ OnError: func(err error) { outputErrs <- err.Error() },
112+ Unlock: unlock,
113+ Status: status,
114+ }
115+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
116+ done := make(chan error, 1)
117+ go func() { done <- guiLocker{}.RunLock(ctx, cfg) }()
118+ // Runs before the compositor is killed (cleanups run in reverse order), so
119+ // the lock goroutine has returned before the test ends, even on failure.
120+ t.Cleanup(func() {
121+ cancel()
122+ select {
123+ case <-done:
124+ case <-time.After(10 * time.Second):
125+ }
126+ })
127+
128+ recv(t, locked)
129+ long := strings.Repeat("x", ports.AuthMaxSecret+1)
130+ _, err := io.WriteString(input, "sleep 200ms\ntype zz\nkey Escape\ntype yy\nkey Ctrl+Shift+u\n"+
131+ "key Return\n"+ // empty after the clears: nothing to submit
132+ "type "+long+"\nkey Return\n"+ // too long: refused
133+ "type abc\nsleep 100ms\nkey BackSpace\ntype d\nsleep 100ms\nkey Return\n")
134+ require.NoError(t, err)
135+ require.Equal(t, []byte("abd"), recv(t, submits), "the first submission is the last secret")
136+ status <- lockFailed // a status change only redraws
137+ select {
138+ case err := <-done:
139+ t.Fatalf("lock ended without an unlock request: %v", err)
140+ case <-time.After(300 * time.Millisecond):
141+ }
142+ unlock <- struct{}{}
143+ require.NoError(t, recv(t, done))
144+ require.Empty(t, submits)
145+ require.Empty(t, outputErrs)
146+}
147+
148+// TestHeadlessFadeEndsAfterReveal fades every output to black, then back,
149+// and checks that the run ends by itself once the reveal completed.
150+func TestHeadlessFadeEndsAfterReveal(t *testing.T) {
151+ startHeadless(t)
152+ m := newFadeModel()
153+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
154+ done := make(chan error, 1)
155+ go func() { done <- guiFader{log: testLog()}.RunFade(ctx, m) }()
156+ t.Cleanup(func() { // before the compositor is killed, even on failure
157+ cancel()
158+ select {
159+ case <-done:
160+ case <-time.After(10 * time.Second):
161+ }
162+ })
163+ m.send(fadeCmd{black: true, dur: 100 * time.Millisecond})
164+ time.Sleep(300 * time.Millisecond)
165+ select {
166+ case err := <-done:
167+ t.Fatalf("fade ended while black: %v", err)
168+ default:
169+ }
170+ m.send(fadeCmd{black: false, dur: 100 * time.Millisecond})
171+ require.NoError(t, recv(t, done))
172+}
@@ -0,0 +1,172 @@
1+package visual
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "syscall"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferafk/internal/ports"
15+ "github.com/bnema/nefergui"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// The headless tests run the real lock and fade loops against a NeferWL
20+// compositor started with two virtual outputs in an isolated runtime
21+// directory. They are skipped unless NEFERAFK_HEADLESS names its binary, and
22+// they only ever connect to that compositor: WAYLAND_DISPLAY is set to its
23+// absolute socket path. Never point them at a real session.
24+
25+var headlessEnvDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
26+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
27+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
28+
29+// startHeadless runs NeferWL, points WAYLAND_DISPLAY at it for this test and
30+// returns its input script writer.
31+func startHeadless(t *testing.T) io.Writer {
32+ t.Helper()
33+ bin := os.Getenv("NEFERAFK_HEADLESS")
34+ if bin == "" {
35+ t.Skip("set NEFERAFK_HEADLESS to a NeferWL binary")
36+ }
37+ bin, err := filepath.Abs(bin)
38+ require.NoError(t, err)
39+ root := t.TempDir()
40+ env := []string{}
41+ for _, e := range os.Environ() {
42+ keep := true
43+ for _, p := range headlessEnvDrop {
44+ keep = keep && !strings.HasPrefix(e, p)
45+ }
46+ if keep {
47+ env = append(env, e)
48+ }
49+ }
50+ run := filepath.Join(root, "run")
51+ for _, name := range []string{"run", "config", "data", "state"} {
52+ require.NoError(t, os.Mkdir(filepath.Join(root, name), 0o700))
53+ env = append(env, "XDG_"+map[string]string{"run": "RUNTIME_DIR", "config": "CONFIG_HOME", "data": "DATA_HOME", "state": "STATE_HOME"}[name]+"="+filepath.Join(root, name))
54+ }
55+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
56+ require.NoError(t, err)
57+ t.Cleanup(func() { _ = logf.Close() })
58+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480,320x240",
59+ "--timeout", "60s", "--input", "-")
60+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
61+ cmd.Env, cmd.Stdout, cmd.Stderr = env, logf, logf
62+ stdin, err := cmd.StdinPipe()
63+ require.NoError(t, err)
64+ require.NoError(t, cmd.Start())
65+ exited := make(chan struct{})
66+ go func() { _ = cmd.Wait(); close(exited) }()
67+ t.Cleanup(func() {
68+ _ = stdin.Close()
69+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
70+ <-exited
71+ })
72+ for deadline := time.Now().Add(15 * time.Second); ; {
73+ paths, _ := filepath.Glob(filepath.Join(run, "wayland-*"))
74+ for _, p := range paths {
75+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
76+ require.True(t, filepath.IsAbs(p))
77+ t.Setenv("WAYLAND_DISPLAY", p) // absolute: XDG_RUNTIME_DIR is not used
78+ return stdin
79+ }
80+ }
81+ select {
82+ case <-exited:
83+ b, _ := os.ReadFile(logf.Name())
84+ t.Fatalf("neferwl exited before serving:\n%s", b)
85+ case <-time.After(5 * time.Millisecond):
86+ }
87+ if time.Now().After(deadline) {
88+ b, _ := os.ReadFile(logf.Name())
89+ t.Fatalf("headless socket timeout:\n%s", b)
90+ }
91+ }
92+}
93+
94+// TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest covers both outputs,
95+// waits for locked and types into the lock. Escape and Ctrl+Shift+U clear the
96+// field, a secret too long for an authentication frame is never submitted,
97+// Enter submits exactly the typed bytes, and only the Unlock request ends the
98+// lock.
99+func TestHeadlessLockSubmitsTypedSecretAndUnlocksOnRequest(t *testing.T) {
100+ input := startHeadless(t)
101+ locked := make(chan struct{}, 1)
102+ submits := make(chan []byte, 4)
103+ outputErrs := make(chan string, 4)
104+ unlock := make(chan struct{}, 1)
105+ status := make(chan lockStatus, 1)
106+ cfg := lockConfig{
107+ View: func(f *nefergui.Frame, st lockState) { lockView(f, st, promptPassword, time.Now()) },
108+ OnLocked: func() { locked <- struct{}{} },
109+ OnSubmit: func(secret []byte) { submits <- append([]byte(nil), secret...) },
110+ OnOutputError: func(output string, _ error) { outputErrs <- output },
111+ OnError: func(err error) { outputErrs <- err.Error() },
112+ Unlock: unlock,
113+ Status: status,
114+ }
115+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
116+ done := make(chan error, 1)
117+ go func() { done <- guiLocker{}.RunLock(ctx, cfg) }()
118+ // Runs before the compositor is killed (cleanups run in reverse order), so
119+ // the lock goroutine has returned before the test ends, even on failure.
120+ t.Cleanup(func() {
121+ cancel()
122+ select {
123+ case <-done:
124+ case <-time.After(10 * time.Second):
125+ }
126+ })
127+
128+ recv(t, locked)
129+ long := strings.Repeat("x", ports.AuthMaxSecret+1)
130+ _, err := io.WriteString(input, "sleep 200ms\ntype zz\nkey Escape\ntype yy\nkey Ctrl+Shift+u\n"+
131+ "key Return\n"+ // empty after the clears: nothing to submit
132+ "type "+long+"\nkey Return\n"+ // too long: refused
133+ "type abc\nsleep 100ms\nkey BackSpace\ntype d\nsleep 100ms\nkey Return\n")
134+ require.NoError(t, err)
135+ require.Equal(t, []byte("abd"), recv(t, submits), "the first submission is the last secret")
136+ status <- lockFailed // a status change only redraws
137+ select {
138+ case err := <-done:
139+ t.Fatalf("lock ended without an unlock request: %v", err)
140+ case <-time.After(300 * time.Millisecond):
141+ }
142+ unlock <- struct{}{}
143+ require.NoError(t, recv(t, done))
144+ require.Empty(t, submits)
145+ require.Empty(t, outputErrs)
146+}
147+
148+// TestHeadlessFadeEndsAfterReveal fades every output to black, then back,
149+// and checks that the run ends by itself once the reveal completed.
150+func TestHeadlessFadeEndsAfterReveal(t *testing.T) {
151+ startHeadless(t)
152+ m := newFadeModel()
153+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
154+ done := make(chan error, 1)
155+ go func() { done <- guiFader{log: testLog()}.RunFade(ctx, m) }()
156+ t.Cleanup(func() { // before the compositor is killed, even on failure
157+ cancel()
158+ select {
159+ case <-done:
160+ case <-time.After(10 * time.Second):
161+ }
162+ })
163+ m.send(fadeCmd{black: true, dur: 100 * time.Millisecond})
164+ time.Sleep(300 * time.Millisecond)
165+ select {
166+ case err := <-done:
167+ t.Fatalf("fade ended while black: %v", err)
168+ default:
169+ }
170+ m.send(fadeCmd{black: false, dur: 100 * time.Millisecond})
171+ require.NoError(t, recv(t, done))
172+}
M internal/adapters/visual/lock.go +14 -21
@@ -15,15 +15,9 @@ import (
1515
1616 var errLockWithoutAuth = errors.New("session lock ended without authentication success")
1717
18-// lockRunner is the session-lock seam over nefergui.RunLock.
18+// lockRunner is the session-lock seam over guiLocker.
1919 type lockRunner interface {
20- RunLock(ctx context.Context, cfg nefergui.LockConfig) error
21-}
22-
23-type guiLocker struct{}
24-
25-func (guiLocker) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
26- return nefergui.RunLock(ctx, cfg)
20+ RunLock(ctx context.Context, cfg lockConfig) error
2721 }
2822
2923 // Config wires the visual process: fd 3 commands in, fd 4 events out.
@@ -39,7 +33,7 @@ type Config struct {
3933 func Run(ctx context.Context, cfg Config) error {
4034 s := &session{
4135 log: cfg.Log.WithField("component", "visual"), cmds: cfg.Commands, events: cfg.Events,
42- locker: guiLocker{}, fader: guiFader{}, launcher: execLauncher{exe: cfg.Executable},
36+ locker: guiLocker{}, fader: guiFader{log: cfg.Log.WithField("component", "visual-fade")}, launcher: execLauncher{exe: cfg.Executable},
4337 }
4438 return s.run(ctx)
4539 }
@@ -215,7 +209,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
215209 return f
216210 }
217211
218-// startLock runs RunLock with the lock view and a private auth controller. The
212+// startLock runs the session lock with the lock view and a private auth controller. The
219213 // lock context is detached from process signals: it ends only with RunLock.
220214 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
221215 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -226,22 +220,16 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
226220 }
227221 go func() { defer close(l.authDone); ctl.run(lctx) }()
228222 go clockWake(lctx.Done(), ctl.wake)
229- go func() {
230- secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
231- err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
232- secret.Wipe()
233- l.done <- err
234- }()
223+ go func() { l.done <- s.locker.RunLock(lctx, newLockConfig(s.log, ctl, l.locked)) }()
235224 return l
236225 }
237226
238-// lockConfig wires NeferGUI's lock loop to the auth controller. OnSubmit only
227+// newLockConfig wires the lock loop to the auth controller. OnSubmit only
239228 // copies the secret; the controller wipes the copy after writing it.
240-func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.SecretBuffer, locked chan<- struct{}) nefergui.LockConfig {
229+func newLockConfig(log zerowrap.Logger, ctl *authController, locked chan<- struct{}) lockConfig {
241230 log = log.WithField("component", "visual-lock")
242- return nefergui.LockConfig{
243- Secret: secret,
244- View: func(f *nefergui.Frame, st nefergui.LockState) {
231+ return lockConfig{
232+ View: func(f *nefergui.Frame, st lockState) {
245233 lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
246234 },
247235 OnLocked: func() {
@@ -255,6 +243,11 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
255243 // black. Only the output name and the error are logged.
256244 log.Warn().Err(err).Str("output", output).Msg("lock surface not created for output")
257245 },
246+ OnError: func(err error) {
247+ // An event failure that leaves the keyboard usable. Messages
248+ // come from neferclient and never carry typed input.
249+ log.Warn().Err(err).Msg("lock event failed")
250+ },
258251 OnSubmit: ctl.submit,
259252 Unlock: ctl.unlock,
260253 Status: ctl.status,
@@ -15,15 +15,9 @@ import (
15 15
16 var errLockWithoutAuth = errors.New("session lock ended without authentication success")16 var errLockWithoutAuth = errors.New("session lock ended without authentication success")
17 17
18-// lockRunner is the session-lock seam over nefergui.RunLock.18+// lockRunner is the session-lock seam over guiLocker.
19 type lockRunner interface {19 type lockRunner interface {
20- RunLock(ctx context.Context, cfg nefergui.LockConfig) error20+ RunLock(ctx context.Context, cfg lockConfig) error
21-}
22-
23-type guiLocker struct{}
24-
25-func (guiLocker) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
26- return nefergui.RunLock(ctx, cfg)
27 }21 }
28 22
29 // Config wires the visual process: fd 3 commands in, fd 4 events out.23 // Config wires the visual process: fd 3 commands in, fd 4 events out.
@@ -39,7 +33,7 @@ type Config struct {
39 func Run(ctx context.Context, cfg Config) error {33 func Run(ctx context.Context, cfg Config) error {
40 s := &session{34 s := &session{
41 log: cfg.Log.WithField("component", "visual"), cmds: cfg.Commands, events: cfg.Events,35 log: cfg.Log.WithField("component", "visual"), cmds: cfg.Commands, events: cfg.Events,
42- locker: guiLocker{}, fader: guiFader{}, launcher: execLauncher{exe: cfg.Executable},36+ locker: guiLocker{}, fader: guiFader{log: cfg.Log.WithField("component", "visual-fade")}, launcher: execLauncher{exe: cfg.Executable},
43 }37 }
44 return s.run(ctx)38 return s.run(ctx)
45 }39 }
@@ -215,7 +209,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun {
215 return f209 return f
216 }210 }
217 211
218-// startLock runs RunLock with the lock view and a private auth controller. The212+// startLock runs the session lock with the lock view and a private auth controller. The
219 // lock context is detached from process signals: it ends only with RunLock.213 // lock context is detached from process signals: it ends only with RunLock.
220 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {214 func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))215 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
@@ -226,22 +220,16 @@ func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
226 }220 }
227 go func() { defer close(l.authDone); ctl.run(lctx) }()221 go func() { defer close(l.authDone); ctl.run(lctx) }()
228 go clockWake(lctx.Done(), ctl.wake)222 go clockWake(lctx.Done(), ctl.wake)
229- go func() {223+ go func() { l.done <- s.locker.RunLock(lctx, newLockConfig(s.log, ctl, l.locked)) }()
230- secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
231- err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
232- secret.Wipe()
233- l.done <- err
234- }()
235 return l224 return l
236 }225 }
237 226
238-// lockConfig wires NeferGUI's lock loop to the auth controller. OnSubmit only227+// newLockConfig wires the lock loop to the auth controller. OnSubmit only
239 // copies the secret; the controller wipes the copy after writing it.228 // copies the secret; the controller wipes the copy after writing it.
240-func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.SecretBuffer, locked chan<- struct{}) nefergui.LockConfig {229+func newLockConfig(log zerowrap.Logger, ctl *authController, locked chan<- struct{}) lockConfig {
241 log = log.WithField("component", "visual-lock")230 log = log.WithField("component", "visual-lock")
242- return nefergui.LockConfig{231+ return lockConfig{
243- Secret: secret,232+ View: func(f *nefergui.Frame, st lockState) {
244- View: func(f *nefergui.Frame, st nefergui.LockState) {
245 lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())233 lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
246 },234 },
247 OnLocked: func() {235 OnLocked: func() {
@@ -255,6 +243,11 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre
255 // black. Only the output name and the error are logged.243 // black. Only the output name and the error are logged.
256 log.Warn().Err(err).Str("output", output).Msg("lock surface not created for output")244 log.Warn().Err(err).Str("output", output).Msg("lock surface not created for output")
257 },245 },
246+ OnError: func(err error) {
247+ // An event failure that leaves the keyboard usable. Messages
248+ // come from neferclient and never carry typed input.
249+ log.Warn().Err(err).Msg("lock event failed")
250+ },
258 OnSubmit: ctl.submit,251 OnSubmit: ctl.submit,
259 Unlock: ctl.unlock,252 Unlock: ctl.unlock,
260 Status: ctl.status,253 Status: ctl.status,
A internal/adapters/visual/screen.go +376 -0
@@ -0,0 +1,376 @@
1+package visual
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/nefergui"
11+)
12+
13+// screen is one Wayland surface and the NeferGUI renderer that draws it. It
14+// copies plain fields between the two libraries, which never import each
15+// other. Everything runs on the owner goroutine that calls Conn.Dispatch.
16+type screen struct {
17+ conn *neferclient.Conn
18+ surf *neferclient.Surface
19+ output uint32 // wl_output registry name
20+ name string // connector name, for error reports
21+ transparent bool // ARGB buffers, presented as non-opaque
22+ view func(*nefergui.Frame)
23+
24+ r *nefergui.Renderer
25+ fb *neferclient.Feedback // feedback r was built from
26+ out nefergui.Output
27+ damage []neferclient.Rect
28+ live map[uint64]int // buffer → release eventfd still watched
29+
30+ configured, canPresent, haveAcquire bool
31+ built bool // a renderer was built once: later setups are rebuilds
32+}
33+
34+func newScreen(conn *neferclient.Conn, surf *neferclient.Surface, o neferclient.Output, transparent bool) *screen {
35+ return &screen{conn: conn, surf: surf, output: o.Global, name: o.Name, transparent: transparent, live: map[uint64]int{}}
36+}
37+
38+// renderView adapts the per-screen view to Renderer.Render without a
39+// per-frame closure.
40+func renderView(f *nefergui.Frame, s *screen) { s.view(f) }
41+
42+// configure applies Handler.Configure. Only the first one lifts the
43+// frame-callback gate; later ones must wait for Handler.Frame.
44+func (s *screen) configure() error {
45+ if !s.configured {
46+ s.canPresent = true
47+ }
48+ s.configured = true
49+ s.resize()
50+ return s.setup()
51+}
52+
53+// setup builds the renderer once the surface is configured and its dmabuf
54+// feedback is complete, and rebuilds it when the compositor changes its
55+// preferred device or formats (a repeated round is not a change).
56+func (s *screen) setup() error {
57+ fb := s.surf.Feedback()
58+ if !s.configured || fb == nil || s.r != nil && fb.Equal(s.fb) {
59+ return nil
60+ }
61+ if err := s.dropRenderer(); err != nil {
62+ return err
63+ }
64+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats)), Transparent: s.transparent}
65+ for i, f := range fb.Formats {
66+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
67+ }
68+ r, err := nefergui.NewRenderer(cfg)
69+ if err != nil {
70+ return fmt.Errorf("renderer: %w", err)
71+ }
72+ s.r, s.fb, s.built = r, fb.Clone(), true // Feedback storage is reused by later rounds
73+ s.resize()
74+ return nil
75+}
76+
77+func (s *screen) resize() {
78+ if s.r != nil {
79+ w, h, scale := s.surf.Size()
80+ s.r.Resize(int(w), int(h), scale)
81+ }
82+}
83+
84+func (s *screen) invalidate() {
85+ if s.r != nil {
86+ s.r.Invalidate()
87+ }
88+}
89+
90+// draw renders when something changed and presents the frame. It reports
91+// whether a built frame still waits for the GPU.
92+func (s *screen) draw() (pending bool, err error) {
93+ if s.r == nil {
94+ return false, nil
95+ }
96+ if s.canPresent {
97+ ok, err := s.r.Render(&s.out, s, renderView)
98+ if err != nil {
99+ return false, fmt.Errorf("render: %w", err)
100+ }
101+ if ok {
102+ if err := s.present(); err != nil {
103+ return false, err
104+ }
105+ }
106+ }
107+ return s.r.Pending(), nil
108+}
109+
110+// watchID packs the surface and a buffer into the Conn.WatchFD id.
111+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
112+
113+// present: Renderer output → ImportBuffer, ImportTimeline, Present. The input
114+// region is not forwarded: it is fixed when the surface is created.
115+func (s *screen) present() error {
116+ out := &s.out
117+ for _, rt := range out.Retired { // buffers of an older size
118+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
119+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
120+ }
121+ delete(s.live, rt.Buffer)
122+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
123+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
124+ }
125+ // The release timeline of a buffer is imported under the buffer's id.
126+ if err := s.surf.DestroyTimeline(rt.Buffer); err != nil {
127+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
128+ }
129+ }
130+ if out.NewBuffer {
131+ buf := neferclient.Buffer{Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier, PlaneCount: out.PlaneCount}
132+ for i, p := range out.Planes {
133+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
134+ }
135+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
136+ return fmt.Errorf("import buffer: %w", err)
137+ }
138+ // The release eventfd is stable per buffer: watched once, FDReady
139+ // hands the buffer back with Renderer.Released.
140+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
141+ return fmt.Errorf("watch release fd: %w", err)
142+ }
143+ s.live[out.Buffer] = out.ReleaseFD
144+ }
145+ if out.NewTimelines {
146+ if !s.haveAcquire { // one acquire timeline shared by every buffer
147+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
148+ return fmt.Errorf("import acquire timeline: %w", err)
149+ }
150+ s.haveAcquire = true
151+ }
152+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
153+ return fmt.Errorf("import release timeline: %w", err)
154+ }
155+ }
156+ s.damage = s.damage[:0]
157+ for _, d := range out.Damage {
158+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
159+ }
160+ if err := s.surf.Present(&neferclient.Present{
161+ Buffer: out.Buffer, AcquireTimeline: out.Acquire.ID, ReleaseTimeline: out.Release.ID,
162+ AcquirePoint: out.AcquirePoint, ReleasePoint: out.ReleasePoint, Damage: s.damage,
163+ Opaque: !s.transparent,
164+ }); err != nil {
165+ return fmt.Errorf("present: %w", err)
166+ }
167+ s.canPresent = false // until Handler.Frame
168+ return nil
169+}
170+
171+// released hands a buffer back after its release eventfd became readable.
172+func (s *screen) released(buffer uint64) error {
173+ if s.r == nil {
174+ return nil
175+ }
176+ if err := s.r.Released(buffer); err != nil {
177+ return fmt.Errorf("release buffer: %w", err)
178+ }
179+ return nil
180+}
181+
182+// dropRenderer stops watching the release eventfds (they belong to the
183+// renderer), destroys the imported buffers and timelines (allowed while a
184+// frame is pending), then closes the renderer.
185+func (s *screen) dropRenderer() error {
186+ if s.r == nil {
187+ return nil
188+ }
189+ var errs []error
190+ for buffer, fd := range s.live {
191+ if err := s.conn.UnwatchFD(fd); err != nil {
192+ errs = append(errs, fmt.Errorf("unwatch buffer %d: %w", buffer, err))
193+ }
194+ }
195+ clear(s.live)
196+ errs = append(errs, s.surf.DestroyImports(), s.r.Close())
197+ s.r, s.fb, s.haveAcquire = nil, nil, false
198+ return errors.Join(errs...)
199+}
200+
201+// close frees the renderer and the surface.
202+func (s *screen) close() error {
203+ return errors.Join(s.dropRenderer(), s.surf.Close())
204+}
205+
206+// closeAfterConn frees what is left once the connection is closed: the
207+// connection dropped the descriptor watches and every Wayland object, so only
208+// the renderer's GPU resources remain.
209+func (s *screen) closeAfterConn() error {
210+ if s.r == nil {
211+ return nil
212+ }
213+ err := s.r.Close()
214+ s.r = nil
215+ return err
216+}
217+
218+// screens holds the surfaces of one connection and implements the Handler
219+// methods they share. Failures from handler methods are kept in err; the
220+// owner loop returns it after Dispatch.
221+type screens struct {
222+ neferclient.NopHandler
223+
224+ conn *neferclient.Conn
225+ byID map[neferclient.SurfaceID]*screen
226+ err error
227+ // onSetup reports a screen whose renderer could not be built (rebuild:
228+ // after a feedback change); it returns the error that ends the run, or
229+ // nil to drop only that screen.
230+ onSetup func(s *screen, rebuild bool, err error) error
231+ // onError reports an event error (Handler.Error); it returns the error
232+ // that ends the run, or nil to go on.
233+ onError func(err error) error
234+}
235+
236+func newScreens(conn *neferclient.Conn) *screens {
237+ ss := &screens{conn: conn, byID: map[neferclient.SurfaceID]*screen{}}
238+ ss.onSetup = func(_ *screen, _ bool, err error) error { return err }
239+ ss.onError = func(err error) error { return err }
240+ return ss
241+}
242+
243+func (ss *screens) fail(err error) {
244+ if ss.err == nil {
245+ ss.err = err
246+ }
247+}
248+
249+func (ss *screens) add(s *screen) { ss.byID[s.surf.ID()] = s }
250+
251+// forget drops a screen and frees it.
252+func (ss *screens) forget(s *screen) error {
253+ delete(ss.byID, s.surf.ID())
254+ return s.close()
255+}
256+
257+// Error applies onError to a failure while handling an event. Connection
258+// failures are not reported here: Conn.Dispatch returns them and they end
259+// the run.
260+func (ss *screens) Error(err error) {
261+ if err = ss.onError(err); err != nil {
262+ ss.fail(err)
263+ }
264+}
265+
266+// keyboardError reports whether a neferclient event error leaves the seat
267+// without a usable keyboard (seat, keyboard or keymap setup). neferclient has
268+// no typed errors for these, so its messages are matched.
269+func keyboardError(err error) bool {
270+ msg := err.Error()
271+ for _, p := range [...]string{"neferclient: keymap", "neferclient: unsupported keymap", "neferclient: keyboard:", "neferclient: bind wl_seat"} {
272+ if strings.HasPrefix(msg, p) {
273+ return true
274+ }
275+ }
276+ return false
277+}
278+
279+func (ss *screens) setupFailed(s *screen, err error) {
280+ if err = ss.onSetup(s, s.built, err); err != nil {
281+ ss.fail(err)
282+ return
283+ }
284+ if err := ss.forget(s); err != nil {
285+ ss.fail(err)
286+ }
287+}
288+
289+func (ss *screens) Configure(id neferclient.SurfaceID, _, _ int32) {
290+ if s := ss.byID[id]; s != nil {
291+ if err := s.configure(); err != nil {
292+ ss.setupFailed(s, err)
293+ }
294+ }
295+}
296+
297+func (ss *screens) FeedbackDone(id neferclient.SurfaceID) {
298+ if s := ss.byID[id]; s != nil {
299+ if err := s.setup(); err != nil {
300+ ss.setupFailed(s, err)
301+ }
302+ }
303+}
304+
305+func (ss *screens) Scale(id neferclient.SurfaceID, _ float64) {
306+ if s := ss.byID[id]; s != nil {
307+ s.resize()
308+ }
309+}
310+
311+func (ss *screens) Frame(id neferclient.SurfaceID) {
312+ if s := ss.byID[id]; s != nil {
313+ s.canPresent = true
314+ }
315+}
316+
317+func (ss *screens) FDReady(id uint64) {
318+ if s := ss.byID[neferclient.SurfaceID(id>>32)]; s != nil {
319+ if err := s.released(id & 0xffffffff); err != nil {
320+ ss.fail(err)
321+ }
322+ }
323+}
324+
325+// OutputRemoved frees the screens of an output that went away.
326+func (ss *screens) OutputRemoved(global uint32) {
327+ for _, s := range ss.byID {
328+ if s.output == global {
329+ if err := ss.forget(s); err != nil {
330+ ss.fail(fmt.Errorf("output %s removed: %w", s.name, err))
331+ }
332+ }
333+ }
334+}
335+
336+// covers reports whether an output already has a screen.
337+func (ss *screens) covers(global uint32) bool {
338+ for _, s := range ss.byID {
339+ if s.output == global {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
346+func (ss *screens) invalidateAll() {
347+ for _, s := range ss.byID {
348+ s.invalidate()
349+ }
350+}
351+
352+// drawAll draws every screen and reports whether one waits for the GPU.
353+func (ss *screens) drawAll() (pending bool, err error) {
354+ for _, s := range ss.byID {
355+ p, err := s.draw()
356+ if err != nil {
357+ return false, fmt.Errorf("output %s: %w", s.name, err)
358+ }
359+ pending = pending || p
360+ }
361+ return pending, nil
362+}
363+
364+// closeAll frees every screen after the connection was closed.
365+func (ss *screens) closeAll() error {
366+ var errs []error
367+ for id, s := range ss.byID {
368+ delete(ss.byID, id)
369+ errs = append(errs, s.closeAfterConn())
370+ }
371+ return errors.Join(errs...)
372+}
373+
374+// gpuRetry is how soon to render again while a frame waits for the GPU; no
375+// event announces that the GPU is done.
376+const gpuRetry = 2 * time.Millisecond
@@ -0,0 +1,376 @@
1+package visual
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/neferclient"
10+ "github.com/bnema/nefergui"
11+)
12+
13+// screen is one Wayland surface and the NeferGUI renderer that draws it. It
14+// copies plain fields between the two libraries, which never import each
15+// other. Everything runs on the owner goroutine that calls Conn.Dispatch.
16+type screen struct {
17+ conn *neferclient.Conn
18+ surf *neferclient.Surface
19+ output uint32 // wl_output registry name
20+ name string // connector name, for error reports
21+ transparent bool // ARGB buffers, presented as non-opaque
22+ view func(*nefergui.Frame)
23+
24+ r *nefergui.Renderer
25+ fb *neferclient.Feedback // feedback r was built from
26+ out nefergui.Output
27+ damage []neferclient.Rect
28+ live map[uint64]int // buffer → release eventfd still watched
29+
30+ configured, canPresent, haveAcquire bool
31+ built bool // a renderer was built once: later setups are rebuilds
32+}
33+
34+func newScreen(conn *neferclient.Conn, surf *neferclient.Surface, o neferclient.Output, transparent bool) *screen {
35+ return &screen{conn: conn, surf: surf, output: o.Global, name: o.Name, transparent: transparent, live: map[uint64]int{}}
36+}
37+
38+// renderView adapts the per-screen view to Renderer.Render without a
39+// per-frame closure.
40+func renderView(f *nefergui.Frame, s *screen) { s.view(f) }
41+
42+// configure applies Handler.Configure. Only the first one lifts the
43+// frame-callback gate; later ones must wait for Handler.Frame.
44+func (s *screen) configure() error {
45+ if !s.configured {
46+ s.canPresent = true
47+ }
48+ s.configured = true
49+ s.resize()
50+ return s.setup()
51+}
52+
53+// setup builds the renderer once the surface is configured and its dmabuf
54+// feedback is complete, and rebuilds it when the compositor changes its
55+// preferred device or formats (a repeated round is not a change).
56+func (s *screen) setup() error {
57+ fb := s.surf.Feedback()
58+ if !s.configured || fb == nil || s.r != nil && fb.Equal(s.fb) {
59+ return nil
60+ }
61+ if err := s.dropRenderer(); err != nil {
62+ return err
63+ }
64+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Formats: make([]nefergui.Format, len(fb.Formats)), Transparent: s.transparent}
65+ for i, f := range fb.Formats {
66+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
67+ }
68+ r, err := nefergui.NewRenderer(cfg)
69+ if err != nil {
70+ return fmt.Errorf("renderer: %w", err)
71+ }
72+ s.r, s.fb, s.built = r, fb.Clone(), true // Feedback storage is reused by later rounds
73+ s.resize()
74+ return nil
75+}
76+
77+func (s *screen) resize() {
78+ if s.r != nil {
79+ w, h, scale := s.surf.Size()
80+ s.r.Resize(int(w), int(h), scale)
81+ }
82+}
83+
84+func (s *screen) invalidate() {
85+ if s.r != nil {
86+ s.r.Invalidate()
87+ }
88+}
89+
90+// draw renders when something changed and presents the frame. It reports
91+// whether a built frame still waits for the GPU.
92+func (s *screen) draw() (pending bool, err error) {
93+ if s.r == nil {
94+ return false, nil
95+ }
96+ if s.canPresent {
97+ ok, err := s.r.Render(&s.out, s, renderView)
98+ if err != nil {
99+ return false, fmt.Errorf("render: %w", err)
100+ }
101+ if ok {
102+ if err := s.present(); err != nil {
103+ return false, err
104+ }
105+ }
106+ }
107+ return s.r.Pending(), nil
108+}
109+
110+// watchID packs the surface and a buffer into the Conn.WatchFD id.
111+func (s *screen) watchID(buffer uint64) uint64 { return uint64(s.surf.ID())<<32 | buffer }
112+
113+// present: Renderer output → ImportBuffer, ImportTimeline, Present. The input
114+// region is not forwarded: it is fixed when the surface is created.
115+func (s *screen) present() error {
116+ out := &s.out
117+ for _, rt := range out.Retired { // buffers of an older size
118+ if err := s.conn.UnwatchFD(rt.ReleaseFD); err != nil {
119+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
120+ }
121+ delete(s.live, rt.Buffer)
122+ if err := s.surf.DestroyBuffer(rt.Buffer); err != nil {
123+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
124+ }
125+ // The release timeline of a buffer is imported under the buffer's id.
126+ if err := s.surf.DestroyTimeline(rt.Buffer); err != nil {
127+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
128+ }
129+ }
130+ if out.NewBuffer {
131+ buf := neferclient.Buffer{Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier, PlaneCount: out.PlaneCount}
132+ for i, p := range out.Planes {
133+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
134+ }
135+ if err := s.surf.ImportBuffer(out.Buffer, &buf); err != nil {
136+ return fmt.Errorf("import buffer: %w", err)
137+ }
138+ // The release eventfd is stable per buffer: watched once, FDReady
139+ // hands the buffer back with Renderer.Released.
140+ if err := s.conn.WatchFD(out.ReleaseFD, s.watchID(out.Buffer)); err != nil {
141+ return fmt.Errorf("watch release fd: %w", err)
142+ }
143+ s.live[out.Buffer] = out.ReleaseFD
144+ }
145+ if out.NewTimelines {
146+ if !s.haveAcquire { // one acquire timeline shared by every buffer
147+ if err := s.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
148+ return fmt.Errorf("import acquire timeline: %w", err)
149+ }
150+ s.haveAcquire = true
151+ }
152+ if err := s.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
153+ return fmt.Errorf("import release timeline: %w", err)
154+ }
155+ }
156+ s.damage = s.damage[:0]
157+ for _, d := range out.Damage {
158+ s.damage = append(s.damage, neferclient.Rect{X: d.X, Y: d.Y, Width: d.Width, Height: d.Height})
159+ }
160+ if err := s.surf.Present(&neferclient.Present{
161+ Buffer: out.Buffer, AcquireTimeline: out.Acquire.ID, ReleaseTimeline: out.Release.ID,
162+ AcquirePoint: out.AcquirePoint, ReleasePoint: out.ReleasePoint, Damage: s.damage,
163+ Opaque: !s.transparent,
164+ }); err != nil {
165+ return fmt.Errorf("present: %w", err)
166+ }
167+ s.canPresent = false // until Handler.Frame
168+ return nil
169+}
170+
171+// released hands a buffer back after its release eventfd became readable.
172+func (s *screen) released(buffer uint64) error {
173+ if s.r == nil {
174+ return nil
175+ }
176+ if err := s.r.Released(buffer); err != nil {
177+ return fmt.Errorf("release buffer: %w", err)
178+ }
179+ return nil
180+}
181+
182+// dropRenderer stops watching the release eventfds (they belong to the
183+// renderer), destroys the imported buffers and timelines (allowed while a
184+// frame is pending), then closes the renderer.
185+func (s *screen) dropRenderer() error {
186+ if s.r == nil {
187+ return nil
188+ }
189+ var errs []error
190+ for buffer, fd := range s.live {
191+ if err := s.conn.UnwatchFD(fd); err != nil {
192+ errs = append(errs, fmt.Errorf("unwatch buffer %d: %w", buffer, err))
193+ }
194+ }
195+ clear(s.live)
196+ errs = append(errs, s.surf.DestroyImports(), s.r.Close())
197+ s.r, s.fb, s.haveAcquire = nil, nil, false
198+ return errors.Join(errs...)
199+}
200+
201+// close frees the renderer and the surface.
202+func (s *screen) close() error {
203+ return errors.Join(s.dropRenderer(), s.surf.Close())
204+}
205+
206+// closeAfterConn frees what is left once the connection is closed: the
207+// connection dropped the descriptor watches and every Wayland object, so only
208+// the renderer's GPU resources remain.
209+func (s *screen) closeAfterConn() error {
210+ if s.r == nil {
211+ return nil
212+ }
213+ err := s.r.Close()
214+ s.r = nil
215+ return err
216+}
217+
218+// screens holds the surfaces of one connection and implements the Handler
219+// methods they share. Failures from handler methods are kept in err; the
220+// owner loop returns it after Dispatch.
221+type screens struct {
222+ neferclient.NopHandler
223+
224+ conn *neferclient.Conn
225+ byID map[neferclient.SurfaceID]*screen
226+ err error
227+ // onSetup reports a screen whose renderer could not be built (rebuild:
228+ // after a feedback change); it returns the error that ends the run, or
229+ // nil to drop only that screen.
230+ onSetup func(s *screen, rebuild bool, err error) error
231+ // onError reports an event error (Handler.Error); it returns the error
232+ // that ends the run, or nil to go on.
233+ onError func(err error) error
234+}
235+
236+func newScreens(conn *neferclient.Conn) *screens {
237+ ss := &screens{conn: conn, byID: map[neferclient.SurfaceID]*screen{}}
238+ ss.onSetup = func(_ *screen, _ bool, err error) error { return err }
239+ ss.onError = func(err error) error { return err }
240+ return ss
241+}
242+
243+func (ss *screens) fail(err error) {
244+ if ss.err == nil {
245+ ss.err = err
246+ }
247+}
248+
249+func (ss *screens) add(s *screen) { ss.byID[s.surf.ID()] = s }
250+
251+// forget drops a screen and frees it.
252+func (ss *screens) forget(s *screen) error {
253+ delete(ss.byID, s.surf.ID())
254+ return s.close()
255+}
256+
257+// Error applies onError to a failure while handling an event. Connection
258+// failures are not reported here: Conn.Dispatch returns them and they end
259+// the run.
260+func (ss *screens) Error(err error) {
261+ if err = ss.onError(err); err != nil {
262+ ss.fail(err)
263+ }
264+}
265+
266+// keyboardError reports whether a neferclient event error leaves the seat
267+// without a usable keyboard (seat, keyboard or keymap setup). neferclient has
268+// no typed errors for these, so its messages are matched.
269+func keyboardError(err error) bool {
270+ msg := err.Error()
271+ for _, p := range [...]string{"neferclient: keymap", "neferclient: unsupported keymap", "neferclient: keyboard:", "neferclient: bind wl_seat"} {
272+ if strings.HasPrefix(msg, p) {
273+ return true
274+ }
275+ }
276+ return false
277+}
278+
279+func (ss *screens) setupFailed(s *screen, err error) {
280+ if err = ss.onSetup(s, s.built, err); err != nil {
281+ ss.fail(err)
282+ return
283+ }
284+ if err := ss.forget(s); err != nil {
285+ ss.fail(err)
286+ }
287+}
288+
289+func (ss *screens) Configure(id neferclient.SurfaceID, _, _ int32) {
290+ if s := ss.byID[id]; s != nil {
291+ if err := s.configure(); err != nil {
292+ ss.setupFailed(s, err)
293+ }
294+ }
295+}
296+
297+func (ss *screens) FeedbackDone(id neferclient.SurfaceID) {
298+ if s := ss.byID[id]; s != nil {
299+ if err := s.setup(); err != nil {
300+ ss.setupFailed(s, err)
301+ }
302+ }
303+}
304+
305+func (ss *screens) Scale(id neferclient.SurfaceID, _ float64) {
306+ if s := ss.byID[id]; s != nil {
307+ s.resize()
308+ }
309+}
310+
311+func (ss *screens) Frame(id neferclient.SurfaceID) {
312+ if s := ss.byID[id]; s != nil {
313+ s.canPresent = true
314+ }
315+}
316+
317+func (ss *screens) FDReady(id uint64) {
318+ if s := ss.byID[neferclient.SurfaceID(id>>32)]; s != nil {
319+ if err := s.released(id & 0xffffffff); err != nil {
320+ ss.fail(err)
321+ }
322+ }
323+}
324+
325+// OutputRemoved frees the screens of an output that went away.
326+func (ss *screens) OutputRemoved(global uint32) {
327+ for _, s := range ss.byID {
328+ if s.output == global {
329+ if err := ss.forget(s); err != nil {
330+ ss.fail(fmt.Errorf("output %s removed: %w", s.name, err))
331+ }
332+ }
333+ }
334+}
335+
336+// covers reports whether an output already has a screen.
337+func (ss *screens) covers(global uint32) bool {
338+ for _, s := range ss.byID {
339+ if s.output == global {
340+ return true
341+ }
342+ }
343+ return false
344+}
345+
346+func (ss *screens) invalidateAll() {
347+ for _, s := range ss.byID {
348+ s.invalidate()
349+ }
350+}
351+
352+// drawAll draws every screen and reports whether one waits for the GPU.
353+func (ss *screens) drawAll() (pending bool, err error) {
354+ for _, s := range ss.byID {
355+ p, err := s.draw()
356+ if err != nil {
357+ return false, fmt.Errorf("output %s: %w", s.name, err)
358+ }
359+ pending = pending || p
360+ }
361+ return pending, nil
362+}
363+
364+// closeAll frees every screen after the connection was closed.
365+func (ss *screens) closeAll() error {
366+ var errs []error
367+ for id, s := range ss.byID {
368+ delete(ss.byID, id)
369+ errs = append(errs, s.closeAfterConn())
370+ }
371+ return errors.Join(errs...)
372+}
373+
374+// gpuRetry is how soon to render again while a frame waits for the GPU; no
375+// event announces that the GPU is done.
376+const gpuRetry = 2 * time.Millisecond
A internal/adapters/visual/screen_test.go +36 -0
@@ -0,0 +1,36 @@
1+package visual
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// The messages are the ones neferclient v0.2.0 reports through Handler.Error.
12+func TestLockErrorPolicyEndsOnlyOnKeyboardFailures(t *testing.T) {
13+ fatal := []error{
14+ errors.New("neferclient: keymap descriptor unavailable"),
15+ fmt.Errorf("neferclient: unsupported keymap format %d or size %d", 2, 0),
16+ fmt.Errorf("neferclient: keymap: %w", errors.New("compile failed")),
17+ fmt.Errorf("neferclient: keyboard: %w", errors.New("xkb context")),
18+ fmt.Errorf("neferclient: bind wl_seat: %w", errors.New("gone")),
19+ }
20+ benign := []error{
21+ fmt.Errorf("neferclient: bind cursor shape manager: %w", errors.New("gone")),
22+ fmt.Errorf("neferclient: dmabuf format table index %d out of range", 9),
23+ errors.New("neferclient: invalid fractional scale zero"),
24+ errors.New("timerfd read: resource temporarily unavailable"),
25+ }
26+ var reported []error
27+ l := &locker{cfg: lockConfig{OnError: func(err error) { reported = append(reported, err) }}}
28+ for _, err := range fatal {
29+ require.ErrorIs(t, l.errorPolicy(err), err, "%v must end the lock run", err)
30+ }
31+ require.Empty(t, reported)
32+ for _, err := range benign {
33+ require.NoError(t, l.errorPolicy(err), "%v must not end the lock run", err)
34+ }
35+ require.Equal(t, benign, reported)
36+}
@@ -0,0 +1,36 @@
1+package visual
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// The messages are the ones neferclient v0.2.0 reports through Handler.Error.
12+func TestLockErrorPolicyEndsOnlyOnKeyboardFailures(t *testing.T) {
13+ fatal := []error{
14+ errors.New("neferclient: keymap descriptor unavailable"),
15+ fmt.Errorf("neferclient: unsupported keymap format %d or size %d", 2, 0),
16+ fmt.Errorf("neferclient: keymap: %w", errors.New("compile failed")),
17+ fmt.Errorf("neferclient: keyboard: %w", errors.New("xkb context")),
18+ fmt.Errorf("neferclient: bind wl_seat: %w", errors.New("gone")),
19+ }
20+ benign := []error{
21+ fmt.Errorf("neferclient: bind cursor shape manager: %w", errors.New("gone")),
22+ fmt.Errorf("neferclient: dmabuf format table index %d out of range", 9),
23+ errors.New("neferclient: invalid fractional scale zero"),
24+ errors.New("timerfd read: resource temporarily unavailable"),
25+ }
26+ var reported []error
27+ l := &locker{cfg: lockConfig{OnError: func(err error) { reported = append(reported, err) }}}
28+ for _, err := range fatal {
29+ require.ErrorIs(t, l.errorPolicy(err), err, "%v must end the lock run", err)
30+ }
31+ require.Empty(t, reported)
32+ for _, err := range benign {
33+ require.NoError(t, l.errorPolicy(err), "%v must not end the lock run", err)
34+ }
35+ require.Equal(t, benign, reported)
36+}
M internal/adapters/visual/session_test.go +11 -12
@@ -10,7 +10,6 @@ import (
1010
1111 "github.com/bnema/neferafk/internal/adapters/visualproc"
1212 "github.com/bnema/neferafk/internal/ports"
13- "github.com/bnema/nefergui"
1413 "github.com/stretchr/testify/mock"
1514 "github.com/stretchr/testify/require"
1615 )
@@ -19,7 +18,7 @@ type harness struct {
1918 cmdW *io.PipeWriter
2019 events *io.PipeReader
2120 done chan error
22- cfgs chan nefergui.LockConfig
21+ cfgs chan lockConfig
2322 l *mockworkerLauncher
2423 lock *mocklockRunner
2524 fade *mockfadeRunner
@@ -30,14 +29,14 @@ type harness struct {
3029 func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) }
3130
3231 // newHarnessRun is newHarness with an optional replacement RunLock body.
33-func newHarnessRun(t *testing.T, runLock func(context.Context, nefergui.LockConfig) error) *harness {
32+func newHarnessRun(t *testing.T, runLock func(context.Context, lockConfig) error) *harness {
3433 t.Helper()
3534 cmdR, cmdW := io.Pipe()
3635 evR, evW := io.Pipe()
37- h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan nefergui.LockConfig, 1),
36+ h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan lockConfig, 1),
3837 l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)}
3938 if runLock == nil {
40- runLock = func(ctx context.Context, cfg nefergui.LockConfig) error {
39+ runLock = func(ctx context.Context, cfg lockConfig) error {
4140 h.cfgs <- cfg
4241 select {
4342 case <-cfg.Unlock:
@@ -78,7 +77,7 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
7877 cfg.OnSubmit([]byte("bad"))
7978 f := w.attempt(t)
8079 w.result(t, f.Attempt, ports.AuthDenied)
81- require.Equal(t, nefergui.LockFailed, recvFailed(t, cfg.Status))
80+ require.Equal(t, lockFailed, recvFailed(t, cfg.Status))
8281 select {
8382 case err := <-h.done:
8483 t.Fatalf("session ended after denial: %v", err)
@@ -93,9 +92,9 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
9392 recv(t, w.killed) // worker reaped with the lock
9493 }
9594
96-func recvFailed(t *testing.T, c <-chan nefergui.LockStatus) nefergui.LockStatus {
95+func recvFailed(t *testing.T, c <-chan lockStatus) lockStatus {
9796 for {
98- if s := recv(t, c); s == nefergui.LockFailed {
97+ if s := recv(t, c); s == lockFailed {
9998 return s
10099 }
101100 }
@@ -195,7 +194,7 @@ func (h *harness) noEvent(t *testing.T) {
195194 }
196195
197196 func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
198- h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return nil })
197+ h := newHarnessRun(t, func(context.Context, lockConfig) error { return nil })
199198 w := expectWorker(t, h.l)
200199 h.sendLock(t)
201200 w.bootstrap(t)
@@ -206,12 +205,12 @@ func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
206205
207206 func TestRunLockErrorNeverReleases(t *testing.T) {
208207 for name, runErr := range map[string]error{
209- "finished": nefergui.ErrLockFinished,
210- "wrapped": fmt.Errorf("compositor: %w", nefergui.ErrLockFinished),
208+ "finished": errLockFinished,
209+ "wrapped": fmt.Errorf("compositor: %w", errLockFinished),
211210 "other": errors.New("boom"),
212211 } {
213212 t.Run(name, func(t *testing.T) {
214- h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return runErr })
213+ h := newHarnessRun(t, func(context.Context, lockConfig) error { return runErr })
215214 w := expectWorker(t, h.l)
216215 h.sendLock(t)
217216 w.bootstrap(t)
@@ -10,7 +10,6 @@ import (
10 10
11 "github.com/bnema/neferafk/internal/adapters/visualproc"11 "github.com/bnema/neferafk/internal/adapters/visualproc"
12 "github.com/bnema/neferafk/internal/ports"12 "github.com/bnema/neferafk/internal/ports"
13- "github.com/bnema/nefergui"
14 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
16 )15 )
@@ -19,7 +18,7 @@ type harness struct {
19 cmdW *io.PipeWriter18 cmdW *io.PipeWriter
20 events *io.PipeReader19 events *io.PipeReader
21 done chan error20 done chan error
22- cfgs chan nefergui.LockConfig21+ cfgs chan lockConfig
23 l *mockworkerLauncher22 l *mockworkerLauncher
24 lock *mocklockRunner23 lock *mocklockRunner
25 fade *mockfadeRunner24 fade *mockfadeRunner
@@ -30,14 +29,14 @@ type harness struct {
30 func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) }29 func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) }
31 30
32 // newHarnessRun is newHarness with an optional replacement RunLock body.31 // newHarnessRun is newHarness with an optional replacement RunLock body.
33-func newHarnessRun(t *testing.T, runLock func(context.Context, nefergui.LockConfig) error) *harness {32+func newHarnessRun(t *testing.T, runLock func(context.Context, lockConfig) error) *harness {
34 t.Helper()33 t.Helper()
35 cmdR, cmdW := io.Pipe()34 cmdR, cmdW := io.Pipe()
36 evR, evW := io.Pipe()35 evR, evW := io.Pipe()
37- h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan nefergui.LockConfig, 1),36+ h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan lockConfig, 1),
38 l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)}37 l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)}
39 if runLock == nil {38 if runLock == nil {
40- runLock = func(ctx context.Context, cfg nefergui.LockConfig) error {39+ runLock = func(ctx context.Context, cfg lockConfig) error {
41 h.cfgs <- cfg40 h.cfgs <- cfg
42 select {41 select {
43 case <-cfg.Unlock:42 case <-cfg.Unlock:
@@ -78,7 +77,7 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
78 cfg.OnSubmit([]byte("bad"))77 cfg.OnSubmit([]byte("bad"))
79 f := w.attempt(t)78 f := w.attempt(t)
80 w.result(t, f.Attempt, ports.AuthDenied)79 w.result(t, f.Attempt, ports.AuthDenied)
81- require.Equal(t, nefergui.LockFailed, recvFailed(t, cfg.Status))80+ require.Equal(t, lockFailed, recvFailed(t, cfg.Status))
82 select {81 select {
83 case err := <-h.done:82 case err := <-h.done:
84 t.Fatalf("session ended after denial: %v", err)83 t.Fatalf("session ended after denial: %v", err)
@@ -93,9 +92,9 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
93 recv(t, w.killed) // worker reaped with the lock92 recv(t, w.killed) // worker reaped with the lock
94 }93 }
95 94
96-func recvFailed(t *testing.T, c <-chan nefergui.LockStatus) nefergui.LockStatus {95+func recvFailed(t *testing.T, c <-chan lockStatus) lockStatus {
97 for {96 for {
98- if s := recv(t, c); s == nefergui.LockFailed {97+ if s := recv(t, c); s == lockFailed {
99 return s98 return s
100 }99 }
101 }100 }
@@ -195,7 +194,7 @@ func (h *harness) noEvent(t *testing.T) {
195 }194 }
196 195
197 func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {196 func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
198- h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return nil })197+ h := newHarnessRun(t, func(context.Context, lockConfig) error { return nil })
199 w := expectWorker(t, h.l)198 w := expectWorker(t, h.l)
200 h.sendLock(t)199 h.sendLock(t)
201 w.bootstrap(t)200 w.bootstrap(t)
@@ -206,12 +205,12 @@ func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
206 205
207 func TestRunLockErrorNeverReleases(t *testing.T) {206 func TestRunLockErrorNeverReleases(t *testing.T) {
208 for name, runErr := range map[string]error{207 for name, runErr := range map[string]error{
209- "finished": nefergui.ErrLockFinished,208+ "finished": errLockFinished,
210- "wrapped": fmt.Errorf("compositor: %w", nefergui.ErrLockFinished),209+ "wrapped": fmt.Errorf("compositor: %w", errLockFinished),
211 "other": errors.New("boom"),210 "other": errors.New("boom"),
212 } {211 } {
213 t.Run(name, func(t *testing.T) {212 t.Run(name, func(t *testing.T) {
214- h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return runErr })213+ h := newHarnessRun(t, func(context.Context, lockConfig) error { return runErr })
215 w := expectWorker(t, h.l)214 w := expectWorker(t, h.l)
216 h.sendLock(t)215 h.sendLock(t)
217 w.bootstrap(t)216 w.bootstrap(t)
M internal/adapters/visual/view.go +6 -6
@@ -69,11 +69,11 @@ func fieldMargin(w int) int {
6969
7070 // hintFor is the line under the input: the attempt status first, then a note
7171 // for prompts the bare field cannot convey. It never carries typed input.
72-func hintFor(s nefergui.LockStatus, p promptKind) string {
72+func hintFor(s lockStatus, p promptKind) string {
7373 switch s {
74- case nefergui.LockBusy:
74+ case lockBusy:
7575 return "..."
76- case nefergui.LockFailed:
76+ case lockFailed:
7777 return "Denied"
7878 }
7979 switch p {
@@ -89,7 +89,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
8989
9090 // maskFor is the field content and style: large dots for a PIN, bullets
9191 // plus a cursor for a password. The count is capped to the field width.
92-func maskFor(s nefergui.LockState, p promptKind) (string, string) {
92+func maskFor(s lockState, p promptKind) (string, string) {
9393 if p == promptPIN {
9494 return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
9595 }
@@ -97,7 +97,7 @@ func maskFor(s nefergui.LockState, p promptKind) (string, string) {
9797 }
9898
9999 // UnlockBox draws the date line, the box and the balancing spacer.
100-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {
100+func UnlockBox(root nefergui.Node, s lockState, p promptKind, now time.Time) {
101101 frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
102102 frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
103103 box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
@@ -109,7 +109,7 @@ func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.
109109 }
110110
111111 // lockView builds the whole lock screen.
112-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {
112+func lockView(f *nefergui.Frame, s lockState, p promptKind, now time.Time) {
113113 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
114114 }
115115
@@ -69,11 +69,11 @@ func fieldMargin(w int) int {
69 69
70 // hintFor is the line under the input: the attempt status first, then a note70 // hintFor is the line under the input: the attempt status first, then a note
71 // for prompts the bare field cannot convey. It never carries typed input.71 // for prompts the bare field cannot convey. It never carries typed input.
72-func hintFor(s nefergui.LockStatus, p promptKind) string {72+func hintFor(s lockStatus, p promptKind) string {
73 switch s {73 switch s {
74- case nefergui.LockBusy:74+ case lockBusy:
75 return "..."75 return "..."
76- case nefergui.LockFailed:76+ case lockFailed:
77 return "Denied"77 return "Denied"
78 }78 }
79 switch p {79 switch p {
@@ -89,7 +89,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string {
89 89
90 // maskFor is the field content and style: large dots for a PIN, bullets90 // maskFor is the field content and style: large dots for a PIN, bullets
91 // plus a cursor for a password. The count is capped to the field width.91 // plus a cursor for a password. The count is capped to the field width.
92-func maskFor(s nefergui.LockState, p promptKind) (string, string) {92+func maskFor(s lockState, p promptKind) (string, string) {
93 if p == promptPIN {93 if p == promptPIN {
94 return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN94 return strings.Repeat("●", min(s.Mask, maxPINMask)), cssMaskPIN
95 }95 }
@@ -97,7 +97,7 @@ func maskFor(s nefergui.LockState, p promptKind) (string, string) {
97 }97 }
98 98
99 // UnlockBox draws the date line, the box and the balancing spacer.99 // UnlockBox draws the date line, the box and the balancing spacer.
100-func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.Time) {100+func UnlockBox(root nefergui.Node, s lockState, p promptKind, now time.Time) {
101 frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))101 frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame))
102 frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))102 frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock))
103 box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))103 box := frame.Column(nefergui.Class("box"), nefergui.Key("box"), nefergui.Inline(cssBox))
@@ -109,7 +109,7 @@ func UnlockBox(root nefergui.Node, s nefergui.LockState, p promptKind, now time.
109 }109 }
110 110
111 // lockView builds the whole lock screen.111 // lockView builds the whole lock screen.
112-func lockView(f *nefergui.Frame, s nefergui.LockState, p promptKind, now time.Time) {112+func lockView(f *nefergui.Frame, s lockState, p promptKind, now time.Time) {
113 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)113 UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now)
114 }114 }
115 115
M internal/adapters/visual/view_test.go +20 -20
@@ -5,7 +5,7 @@ import (
55 "time"
66 "unicode/utf8"
77
8- "github.com/bnema/nefergui"
8+ "github.com/bnema/neferclient"
99 "github.com/stretchr/testify/require"
1010 )
1111
@@ -43,31 +43,31 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) {
4343 }
4444
4545 func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
46- l := fadeLayer()
47- require.True(t, l.AllOutputs)
48- require.Empty(t, l.Output)
49- require.Equal(t, nefergui.LayerOverlay, l.Level)
50- require.Equal(t, nefergui.KeyboardNone, l.Keyboard)
46+ l := fadeLayer("DP-1")
47+ require.Equal(t, "DP-1", l.Output)
48+ require.Equal(t, neferclient.LayerOverlay, l.Level)
49+ require.Equal(t, neferclient.KeyboardNone, l.Keyboard)
50+ require.Equal(t, int32(-1), l.ExclusiveZone)
5151 require.NotNil(t, l.InputRects)
5252 require.Empty(t, l.InputRects)
53- all := nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight
53+ all := neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight
5454 require.Equal(t, all, l.Anchors)
5555 }
5656
5757 func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
5858 for _, tc := range []struct {
59- s nefergui.LockStatus
59+ s lockStatus
6060 p promptKind
6161 want string
6262 }{
63- {nefergui.LockFailed, promptFallback, "Denied"},
64- {nefergui.LockFailed, promptMore, "Denied"},
65- {nefergui.LockBusy, promptPIN, "..."},
66- {nefergui.LockIdle, promptFallback, "PIN off, use password"},
67- {nefergui.LockIdle, promptUnavailable, "Authentication unavailable"},
68- {nefergui.LockIdle, promptMore, "Enter again"},
69- {nefergui.LockIdle, promptPassword, " "},
70- {nefergui.LockIdle, promptPIN, " "},
63+ {lockFailed, promptFallback, "Denied"},
64+ {lockFailed, promptMore, "Denied"},
65+ {lockBusy, promptPIN, "..."},
66+ {lockIdle, promptFallback, "PIN off, use password"},
67+ {lockIdle, promptUnavailable, "Authentication unavailable"},
68+ {lockIdle, promptMore, "Enter again"},
69+ {lockIdle, promptPassword, " "},
70+ {lockIdle, promptPIN, " "},
7171 } {
7272 got := hintFor(tc.s, tc.p)
7373 require.Equal(t, tc.want, got)
@@ -76,13 +76,13 @@ func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
7676 }
7777
7878 func TestMaskIsCappedToTheField(t *testing.T) {
79- mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)
79+ mask, _ := maskFor(lockState{Mask: 3}, promptPIN)
8080 require.Equal(t, "●●●", mask)
81- mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)
81+ mask, _ = maskFor(lockState{Mask: 3}, promptFallback)
8282 require.Equal(t, "•••_", mask)
83- mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPIN)
83+ mask, _ = maskFor(lockState{Mask: 512}, promptPIN)
8484 require.Equal(t, maxPINMask, utf8.RuneCountInString(mask))
85- mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPassword)
85+ mask, _ = maskFor(lockState{Mask: 512}, promptPassword)
8686 require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask))
8787 }
8888
@@ -5,7 +5,7 @@ import (
5 "time"5 "time"
6 "unicode/utf8"6 "unicode/utf8"
7 7
8- "github.com/bnema/nefergui"8+ "github.com/bnema/neferclient"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
@@ -43,31 +43,31 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) {
43 }43 }
44 44
45 func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {45 func TestFadeLayerIsClickThroughOverlayOnEveryOutput(t *testing.T) {
46- l := fadeLayer()46+ l := fadeLayer("DP-1")
47- require.True(t, l.AllOutputs)47+ require.Equal(t, "DP-1", l.Output)
48- require.Empty(t, l.Output)48+ require.Equal(t, neferclient.LayerOverlay, l.Level)
49- require.Equal(t, nefergui.LayerOverlay, l.Level)49+ require.Equal(t, neferclient.KeyboardNone, l.Keyboard)
50- require.Equal(t, nefergui.KeyboardNone, l.Keyboard)50+ require.Equal(t, int32(-1), l.ExclusiveZone)
51 require.NotNil(t, l.InputRects)51 require.NotNil(t, l.InputRects)
52 require.Empty(t, l.InputRects)52 require.Empty(t, l.InputRects)
53- all := nefergui.AnchorTop | nefergui.AnchorBottom | nefergui.AnchorLeft | nefergui.AnchorRight53+ all := neferclient.AnchorTop | neferclient.AnchorBottom | neferclient.AnchorLeft | neferclient.AnchorRight
54 require.Equal(t, all, l.Anchors)54 require.Equal(t, all, l.Anchors)
55 }55 }
56 56
57 func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {57 func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
58 for _, tc := range []struct {58 for _, tc := range []struct {
59- s nefergui.LockStatus59+ s lockStatus
60 p promptKind60 p promptKind
61 want string61 want string
62 }{62 }{
63- {nefergui.LockFailed, promptFallback, "Denied"},63+ {lockFailed, promptFallback, "Denied"},
64- {nefergui.LockFailed, promptMore, "Denied"},64+ {lockFailed, promptMore, "Denied"},
65- {nefergui.LockBusy, promptPIN, "..."},65+ {lockBusy, promptPIN, "..."},
66- {nefergui.LockIdle, promptFallback, "PIN off, use password"},66+ {lockIdle, promptFallback, "PIN off, use password"},
67- {nefergui.LockIdle, promptUnavailable, "Authentication unavailable"},67+ {lockIdle, promptUnavailable, "Authentication unavailable"},
68- {nefergui.LockIdle, promptMore, "Enter again"},68+ {lockIdle, promptMore, "Enter again"},
69- {nefergui.LockIdle, promptPassword, " "},69+ {lockIdle, promptPassword, " "},
70- {nefergui.LockIdle, promptPIN, " "},70+ {lockIdle, promptPIN, " "},
71 } {71 } {
72 got := hintFor(tc.s, tc.p)72 got := hintFor(tc.s, tc.p)
73 require.Equal(t, tc.want, got)73 require.Equal(t, tc.want, got)
@@ -76,13 +76,13 @@ func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) {
76 }76 }
77 77
78 func TestMaskIsCappedToTheField(t *testing.T) {78 func TestMaskIsCappedToTheField(t *testing.T) {
79- mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN)79+ mask, _ := maskFor(lockState{Mask: 3}, promptPIN)
80 require.Equal(t, "●●●", mask)80 require.Equal(t, "●●●", mask)
81- mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback)81+ mask, _ = maskFor(lockState{Mask: 3}, promptFallback)
82 require.Equal(t, "•••_", mask)82 require.Equal(t, "•••_", mask)
83- mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPIN)83+ mask, _ = maskFor(lockState{Mask: 512}, promptPIN)
84 require.Equal(t, maxPINMask, utf8.RuneCountInString(mask))84 require.Equal(t, maxPINMask, utf8.RuneCountInString(mask))
85- mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPassword)85+ mask, _ = maskFor(lockState{Mask: 512}, promptPassword)
86 require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask))86 require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask))
87 }87 }
88 88
M internal/adapters/visual/visual_mock_test.go +7 -8
@@ -8,7 +8,6 @@ import (
88 "context"
99 "io"
1010
11- "github.com/bnema/nefergui"
1211 mock "github.com/stretchr/testify/mock"
1312 )
1413
@@ -421,7 +420,7 @@ func (_m *mocklockRunner) EXPECT() *mocklockRunner_Expecter {
421420 }
422421
423422 // RunLock provides a mock function for the type mocklockRunner
424-func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
423+func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg lockConfig) error {
425424 ret := _mock.Called(ctx, cfg)
426425
427426 if len(ret) == 0 {
@@ -429,7 +428,7 @@ func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfi
429428 }
430429
431430 var r0 error
432- if returnFunc, ok := ret.Get(0).(func(context.Context, nefergui.LockConfig) error); ok {
431+ if returnFunc, ok := ret.Get(0).(func(context.Context, lockConfig) error); ok {
433432 r0 = returnFunc(ctx, cfg)
434433 } else {
435434 r0 = ret.Error(0)
@@ -444,20 +443,20 @@ type mocklockRunner_RunLock_Call struct {
444443
445444 // RunLock is a helper method to define mock.On call
446445 // - ctx context.Context
447-// - cfg nefergui.LockConfig
446+// - cfg lockConfig
448447 func (_e *mocklockRunner_Expecter) RunLock(ctx any, cfg any) *mocklockRunner_RunLock_Call {
449448 return &mocklockRunner_RunLock_Call{Call: _e.mock.On("RunLock", ctx, cfg)}
450449 }
451450
452-func (_c *mocklockRunner_RunLock_Call) Run(run func(ctx context.Context, cfg nefergui.LockConfig)) *mocklockRunner_RunLock_Call {
451+func (_c *mocklockRunner_RunLock_Call) Run(run func(ctx context.Context, cfg lockConfig)) *mocklockRunner_RunLock_Call {
453452 _c.Call.Run(func(args mock.Arguments) {
454453 var arg0 context.Context
455454 if args[0] != nil {
456455 arg0 = args[0].(context.Context)
457456 }
458- var arg1 nefergui.LockConfig
457+ var arg1 lockConfig
459458 if args[1] != nil {
460- arg1 = args[1].(nefergui.LockConfig)
459+ arg1 = args[1].(lockConfig)
461460 }
462461 run(
463462 arg0,
@@ -472,7 +471,7 @@ func (_c *mocklockRunner_RunLock_Call) Return(err error) *mocklockRunner_RunLock
472471 return _c
473472 }
474473
475-func (_c *mocklockRunner_RunLock_Call) RunAndReturn(run func(ctx context.Context, cfg nefergui.LockConfig) error) *mocklockRunner_RunLock_Call {
474+func (_c *mocklockRunner_RunLock_Call) RunAndReturn(run func(ctx context.Context, cfg lockConfig) error) *mocklockRunner_RunLock_Call {
476475 _c.Call.Return(run)
477476 return _c
478477 }
@@ -8,7 +8,6 @@ import (
8 "context"8 "context"
9 "io"9 "io"
10 10
11- "github.com/bnema/nefergui"
12 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
13 )12 )
14 13
@@ -421,7 +420,7 @@ func (_m *mocklockRunner) EXPECT() *mocklockRunner_Expecter {
421 }420 }
422 421
423 // RunLock provides a mock function for the type mocklockRunner422 // RunLock provides a mock function for the type mocklockRunner
424-func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {423+func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg lockConfig) error {
425 ret := _mock.Called(ctx, cfg)424 ret := _mock.Called(ctx, cfg)
426 425
427 if len(ret) == 0 {426 if len(ret) == 0 {
@@ -429,7 +428,7 @@ func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfi
429 }428 }
430 429
431 var r0 error430 var r0 error
432- if returnFunc, ok := ret.Get(0).(func(context.Context, nefergui.LockConfig) error); ok {431+ if returnFunc, ok := ret.Get(0).(func(context.Context, lockConfig) error); ok {
433 r0 = returnFunc(ctx, cfg)432 r0 = returnFunc(ctx, cfg)
434 } else {433 } else {
435 r0 = ret.Error(0)434 r0 = ret.Error(0)
@@ -444,20 +443,20 @@ type mocklockRunner_RunLock_Call struct {
444 443
445 // RunLock is a helper method to define mock.On call444 // RunLock is a helper method to define mock.On call
446 // - ctx context.Context445 // - ctx context.Context
447-// - cfg nefergui.LockConfig446+// - cfg lockConfig
448 func (_e *mocklockRunner_Expecter) RunLock(ctx any, cfg any) *mocklockRunner_RunLock_Call {447 func (_e *mocklockRunner_Expecter) RunLock(ctx any, cfg any) *mocklockRunner_RunLock_Call {
449 return &mocklockRunner_RunLock_Call{Call: _e.mock.On("RunLock", ctx, cfg)}448 return &mocklockRunner_RunLock_Call{Call: _e.mock.On("RunLock", ctx, cfg)}
450 }449 }
451 450
452-func (_c *mocklockRunner_RunLock_Call) Run(run func(ctx context.Context, cfg nefergui.LockConfig)) *mocklockRunner_RunLock_Call {451+func (_c *mocklockRunner_RunLock_Call) Run(run func(ctx context.Context, cfg lockConfig)) *mocklockRunner_RunLock_Call {
453 _c.Call.Run(func(args mock.Arguments) {452 _c.Call.Run(func(args mock.Arguments) {
454 var arg0 context.Context453 var arg0 context.Context
455 if args[0] != nil {454 if args[0] != nil {
456 arg0 = args[0].(context.Context)455 arg0 = args[0].(context.Context)
457 }456 }
458- var arg1 nefergui.LockConfig457+ var arg1 lockConfig
459 if args[1] != nil {458 if args[1] != nil {
460- arg1 = args[1].(nefergui.LockConfig)459+ arg1 = args[1].(lockConfig)
461 }460 }
462 run(461 run(
463 arg0,462 arg0,
@@ -472,7 +471,7 @@ func (_c *mocklockRunner_RunLock_Call) Return(err error) *mocklockRunner_RunLock
472 return _c471 return _c
473 }472 }
474 473
475-func (_c *mocklockRunner_RunLock_Call) RunAndReturn(run func(ctx context.Context, cfg nefergui.LockConfig) error) *mocklockRunner_RunLock_Call {474+func (_c *mocklockRunner_RunLock_Call) RunAndReturn(run func(ctx context.Context, cfg lockConfig) error) *mocklockRunner_RunLock_Call {
476 _c.Call.Return(run)475 _c.Call.Return(run)
477 return _c476 return _c
478 }477 }
M internal/adapters/visual/worker_test.go +1 -1
@@ -29,7 +29,7 @@ func TestLockConfigReportsSkippedOutputWithComponent(t *testing.T) {
2929 var buf bytes.Buffer
3030 log := zerowrap.New(zerowrap.Config{Output: &buf})
3131 ctl := newAuthController(testLog(), nil, boot(), time.Minute)
32- cfg := lockConfig(log, ctl, nil, make(chan struct{}, 1))
32+ cfg := newLockConfig(log, ctl, make(chan struct{}, 1))
3333 require.NotNil(t, cfg.OnOutputError)
3434 cfg.OnOutputError("HDMI-A-1", errors.New("surface failed"))
3535 out := buf.String()
@@ -29,7 +29,7 @@ func TestLockConfigReportsSkippedOutputWithComponent(t *testing.T) {
29 var buf bytes.Buffer29 var buf bytes.Buffer
30 log := zerowrap.New(zerowrap.Config{Output: &buf})30 log := zerowrap.New(zerowrap.Config{Output: &buf})
31 ctl := newAuthController(testLog(), nil, boot(), time.Minute)31 ctl := newAuthController(testLog(), nil, boot(), time.Minute)
32- cfg := lockConfig(log, ctl, nil, make(chan struct{}, 1))32+ cfg := newLockConfig(log, ctl, make(chan struct{}, 1))
33 require.NotNil(t, cfg.OnOutputError)33 require.NotNil(t, cfg.OnOutputError)
34 cfg.OnOutputError("HDMI-A-1", errors.New("surface failed"))34 cfg.OnOutputError("HDMI-A-1", errors.New("surface failed"))
35 out := buf.String()35 out := buf.String()
M internal/adapters/wayland/client.go +3 -3
@@ -11,11 +11,11 @@ import (
1111 "sort"
1212 "time"
1313
14+ idlenotify "github.com/bnema/go-wayland-bindings/client/extidlenotify"
15+ core "github.com/bnema/go-wayland-bindings/client/wayland"
16+ outputpower "github.com/bnema/go-wayland-bindings/client/wlroutputpowermanagement"
1417 "github.com/bnema/neferafk/internal/ports"
1518 "github.com/bnema/wlturbo"
16- "github.com/bnema/wlturbo/protocol/core"
17- "github.com/bnema/wlturbo/protocol/idlenotify"
18- "github.com/bnema/wlturbo/protocol/outputpower"
1919 "github.com/bnema/zerowrap"
2020 )
2121
@@ -11,11 +11,11 @@ import (
11 "sort"11 "sort"
12 "time"12 "time"
13 13
14+ idlenotify "github.com/bnema/go-wayland-bindings/client/extidlenotify"
15+ core "github.com/bnema/go-wayland-bindings/client/wayland"
16+ outputpower "github.com/bnema/go-wayland-bindings/client/wlroutputpowermanagement"
14 "github.com/bnema/neferafk/internal/ports"17 "github.com/bnema/neferafk/internal/ports"
15 "github.com/bnema/wlturbo"18 "github.com/bnema/wlturbo"
16- "github.com/bnema/wlturbo/protocol/core"
17- "github.com/bnema/wlturbo/protocol/idlenotify"
18- "github.com/bnema/wlturbo/protocol/outputpower"
19 "github.com/bnema/zerowrap"19 "github.com/bnema/zerowrap"
20 )20 )
21 21

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