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 Unverified
B5690EEEBB952194The key is unknown
18 files · +1168 -141
M AGENTS.md +1 -1
| @@ -6,5 +6,5 @@ | |||
| 6 | 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 | 7 | - Signed conventional commits only; no remote/publication/installation or live lock/off/suspend/real credential attempt without authorization. | |
| 8 | 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 | 10 | - 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 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 7 | ## Behaviour | |
| 8 | 8 | ||
| @@ -92,6 +92,12 @@ make race # race tests (CGO_ENABLED=1) | |||
| 92 | 92 | 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 | 101 | ## License | |
| 96 | 102 | ||
| 97 | 103 | [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 | ## Behaviour | 7 | ## Behaviour | ||
| 8 | 8 | ||||
| @@ -92,6 +92,12 @@ make race # race tests (CGO_ENABLED=1) | |||||
| 92 | make mocks-check # Mockery v3 mocks are up to date | 92 | 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 | ## License | 101 | ## 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 | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | 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 | |
| 7 | 9 | github.com/bnema/purego-pam v0.1.0 | |
| 8 | - | github.com/bnema/wlturbo v0.5.0 | |
| 10 | + | github.com/bnema/wlturbo v0.6.2 | |
| 9 | 11 | github.com/bnema/zerowrap v1.4.1 | |
| 10 | 12 | github.com/godbus/dbus/v5 v5.2.2 | |
| 11 | 13 | github.com/stretchr/testify v1.12.1 | |
| @@ -15,7 +17,7 @@ require ( | |||
| 15 | 17 | require ( | |
| 16 | 18 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | |
| 17 | 19 | 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 | |
| 19 | 21 | github.com/go-text/typesetting v0.3.5 // indirect | |
| 20 | 22 | github.com/mattn/go-colorable v0.1.15 // indirect | |
| 21 | 23 | github.com/mattn/go-isatty v0.0.22 // indirect | |
| @@ -3,9 +3,11 @@ module github.com/bnema/neferafk | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | 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 | |||
| 7 | github.com/bnema/purego-pam v0.1.0 | 9 | github.com/bnema/purego-pam v0.1.0 | ||
| 8 | - | github.com/bnema/wlturbo v0.5.0 | 10 | + | github.com/bnema/wlturbo v0.6.2 |
| 9 | github.com/bnema/zerowrap v1.4.1 | 11 | github.com/bnema/zerowrap v1.4.1 | ||
| 10 | github.com/godbus/dbus/v5 v5.2.2 | 12 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 11 | github.com/stretchr/testify v1.12.1 | 13 | 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 // indirect | 18 | github.com/bnema/purego v0.13.0-bnema.1 // indirect | ||
| 17 | github.com/bnema/purego-vulkan v0.6.0 // indirect | 19 | 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 |
| 19 | github.com/go-text/typesetting v0.3.5 // indirect | 21 | github.com/go-text/typesetting v0.3.5 // indirect | ||
| 20 | github.com/mattn/go-colorable v0.1.15 // indirect | 22 | github.com/mattn/go-colorable v0.1.15 // indirect | ||
| 21 | github.com/mattn/go-isatty v0.0.22 // indirect | 23 | 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/neferafk/internal/adapters/auth" | |
| 10 | 10 | "github.com/bnema/neferafk/internal/ports" | |
| 11 | - | "github.com/bnema/nefergui" | |
| 12 | 11 | "github.com/bnema/zerowrap" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| @@ -80,7 +79,7 @@ type authController struct { | |||
| 80 | 79 | timeout time.Duration | |
| 81 | 80 | ||
| 82 | 81 | submits chan []byte | |
| 83 | - | status chan nefergui.LockStatus | |
| 82 | + | status chan lockStatus | |
| 84 | 83 | unlock chan struct{} | |
| 85 | 84 | wake chan struct{} | |
| 86 | 85 | ||
| @@ -99,7 +98,7 @@ type authController struct { | |||
| 99 | 98 | func newAuthController(log zerowrap.Logger, l workerLauncher, boot ports.AuthBootstrap, timeout time.Duration) *authController { | |
| 100 | 99 | return &authController{ | |
| 101 | 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 | 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 | 118 | for { | |
| 120 | 119 | select { | |
| 121 | 120 | case c.status <- s: | |
| @@ -272,7 +271,7 @@ func (c *authController) workerLost(failed bool) { | |||
| 272 | 271 | c.setPrompt(promptNone) | |
| 273 | 272 | } | |
| 274 | 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 | 284 | case phaseNoWorker: | |
| 286 | 285 | clear(c.pending) | |
| 287 | 286 | c.pending = s | |
| 288 | - | c.setStatus(nefergui.LockBusy) | |
| 287 | + | c.setStatus(lockBusy) | |
| 289 | 288 | c.spawn(ctx) | |
| 290 | 289 | if c.w == nil { // launch failed | |
| 291 | 290 | clear(c.pending) | |
| 292 | 291 | c.pending = nil | |
| 293 | - | c.setStatus(nefergui.LockFailed) | |
| 292 | + | c.setStatus(lockFailed) | |
| 294 | 293 | } | |
| 295 | 294 | case phaseStarting: // keep only the latest submission | |
| 296 | 295 | clear(c.pending) | |
| @@ -308,7 +307,7 @@ func (c *authController) write(f ports.AuthFrame) bool { | |||
| 308 | 307 | ||
| 309 | 308 | func (c *authController) sendAttempt(s []byte) { | |
| 310 | 309 | c.attempt++ | |
| 311 | - | c.setStatus(nefergui.LockBusy) | |
| 310 | + | c.setStatus(lockBusy) | |
| 312 | 311 | c.ph = phaseAttempt | |
| 313 | 312 | c.arm() | |
| 314 | 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 | 318 | func (c *authController) sendSecret(s []byte) { | |
| 320 | - | c.setStatus(nefergui.LockBusy) | |
| 319 | + | c.setStatus(lockBusy) | |
| 321 | 320 | c.ph = phaseAttempt | |
| 322 | 321 | c.arm() | |
| 323 | 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 | 369 | c.ph = phasePrompt | |
| 371 | 370 | c.setPrompt(promptMore) | |
| 372 | - | c.setStatus(nefergui.LockIdle) | |
| 371 | + | c.setStatus(lockIdle) | |
| 373 | 372 | case ports.AuthStatus: | |
| 374 | 373 | // PAM text is deliberately never shown. | |
| 375 | 374 | case ports.AuthResult: | |
| @@ -385,7 +384,7 @@ func (c *authController) onFrame(m frameMsg) bool { | |||
| 385 | 384 | return true | |
| 386 | 385 | } | |
| 387 | 386 | c.ph = phaseReady | |
| 388 | - | c.setStatus(nefergui.LockFailed) | |
| 387 | + | c.setStatus(lockFailed) | |
| 389 | 388 | default: | |
| 390 | 389 | c.workerLost(true) | |
| 391 | 390 | } | |
| @@ -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.Duration | 79 | timeout time.Duration | ||
| 81 | 80 | ||||
| 82 | submits chan []byte | 81 | submits chan []byte | ||
| 83 | - | status chan nefergui.LockStatus | 82 | + | 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 = s | 286 | 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 failed | 289 | if c.w == nil { // launch failed | ||
| 291 | clear(c.pending) | 290 | clear(c.pending) | ||
| 292 | c.pending = nil | 291 | c.pending = nil | ||
| 293 | - | c.setStatus(nefergui.LockFailed) | 292 | + | c.setStatus(lockFailed) |
| 294 | } | 293 | } | ||
| 295 | case phaseStarting: // keep only the latest submission | 294 | 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 = phaseAttempt | 311 | 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 = phaseAttempt | 320 | 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 = phasePrompt | 369 | 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 true | 384 | return true | ||
| 386 | } | 385 | } | ||
| 387 | c.ph = phaseReady | 386 | 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/neferafk/internal/adapters/auth" | |
| 11 | 11 | "github.com/bnema/neferafk/internal/ports" | |
| 12 | - | "github.com/bnema/nefergui" | |
| 13 | 12 | "github.com/bnema/zerowrap" | |
| 14 | 13 | "github.com/stretchr/testify/mock" | |
| 15 | 14 | "github.com/stretchr/testify/require" | |
| @@ -124,7 +123,7 @@ func TestDeniedStaysLockedAndAllowsRetry(t *testing.T) { | |||
| 124 | 123 | c.submit([]byte("bad")) | |
| 125 | 124 | f := w.attempt(t) | |
| 126 | 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 | 128 | select { | |
| 130 | 129 | case <-c.unlock: | |
| @@ -148,7 +147,7 @@ func TestWorkerCrashStaysLockedAndRespawnsOnNextAttempt(t *testing.T) { | |||
| 148 | 147 | w1.attempt(t) | |
| 149 | 148 | w1.toVisual.Close() // worker died mid-attempt | |
| 150 | 149 | recv(t, w1.killed) | |
| 151 | - | for recv(t, c.status) != nefergui.LockFailed { | |
| 150 | + | for recv(t, c.status) != lockFailed { | |
| 152 | 151 | } | |
| 153 | 152 | require.False(t, c.succeeded.Load()) | |
| 154 | 153 | w2 := expectWorker(t, l) | |
| @@ -170,7 +169,7 @@ func TestWatchdogKillsHungWorkerAndStaysLocked(t *testing.T) { | |||
| 170 | 169 | c.submit([]byte("x")) | |
| 171 | 170 | w.attempt(t) // no result ever comes | |
| 172 | 171 | recv(t, w.killed) | |
| 173 | - | for recv(t, c.status) != nefergui.LockFailed { | |
| 172 | + | for recv(t, c.status) != lockFailed { | |
| 174 | 173 | } | |
| 175 | 174 | select { | |
| 176 | 175 | case <-c.unlock: | |
| @@ -271,14 +270,14 @@ func TestControllerKillsWorkerOnStaleGenerationOrAttempt(t *testing.T) { | |||
| 271 | 270 | w.ready(t) | |
| 272 | 271 | c.submit([]byte("first")) | |
| 273 | 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 | 275 | c.submit([]byte("second")) // attempt 2: attempt 1 is now stale | |
| 277 | 276 | f := w.attempt(t) | |
| 278 | 277 | require.EqualValues(t, 2, f.Attempt) | |
| 279 | 278 | require.NoError(t, auth.WriteFrame(w.toVisual, mk(f.Attempt))) | |
| 280 | 279 | recv(t, w.killed) | |
| 281 | - | for recv(t, c.status) != nefergui.LockFailed { | |
| 280 | + | for recv(t, c.status) != lockFailed { | |
| 282 | 281 | } | |
| 283 | 282 | select { | |
| 284 | 283 | 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-attempt | 148 | 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 comes | 170 | 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 stale | 275 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 10 | + | "github.com/bnema/neferclient" | |
| 9 | 11 | "github.com/bnema/nefergui" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 10 | 13 | ) | |
| 11 | 14 | ||
| 12 | 15 | // fadeTick is the redraw cadence while the fade animates. | |
| @@ -19,8 +22,8 @@ type fadeCmd struct { | |||
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | 24 | // 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. | |
| 24 | 27 | type fadeModel struct { | |
| 25 | 28 | cmds chan fadeCmd | |
| 26 | 29 | wake chan struct{} | |
| @@ -34,7 +37,7 @@ type fadeModel struct { | |||
| 34 | 37 | // the loop starts. | |
| 35 | 38 | finish context.CancelFunc | |
| 36 | 39 | ||
| 37 | - | // owned by the view: | |
| 40 | + | // owned by the RunFade goroutine: | |
| 38 | 41 | started bool | |
| 39 | 42 | from, to float64 | |
| 40 | 43 | start time.Time | |
| @@ -124,55 +127,150 @@ type fadeRunner interface { | |||
| 124 | 127 | RunFade(ctx context.Context, m *fadeModel) error | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 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 } | |
| 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 | 135 | ctx, cancel := context.WithCancel(ctx) | |
| 132 | 136 | defer cancel() | |
| 133 | 137 | 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 | |
| 151 | 142 | } | |
| 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") | |
| 161 | 151 | return nil | |
| 162 | 152 | } | |
| 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 | + | } | |
| 164 | 262 | } | |
| 165 | 263 | ||
| 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, | |
| 171 | 269 | 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, | |
| 175 | 273 | ExclusiveZone: -1, | |
| 176 | - | InputRects: []nefergui.Rect{}, | |
| 274 | + | InputRects: []neferclient.Rect{}, | |
| 177 | 275 | } | |
| 178 | 276 | } | |
| @@ -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 every | 24 | // 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 | 25 | + | // 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 fadeCmd | 28 | 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.CancelFunc | 38 | finish context.CancelFunc | ||
| 36 | 39 | ||||
| 37 | - | // owned by the view: | 40 | + | // owned by the RunFade goroutine: |
| 38 | started bool | 41 | started bool | ||
| 39 | from, to float64 | 42 | from, to float64 | ||
| 40 | start time.Time | 43 | start time.Time | ||
| @@ -124,55 +127,150 @@ type fadeRunner interface { | |||||
| 124 | RunFade(ctx context.Context, m *fadeModel) error | 127 | 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 = cancel | 137 | 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 := false | 141 | + | 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 nil | 151 | return nil | ||
| 162 | } | 152 | } | ||
| 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 | + | } | |||
| 164 | } | 262 | } | ||
| 165 | 263 | ||||
| 166 | - | // fadeLayer: overlay on every output, all anchors, no keyboard, fully | 264 | + | // 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 ( | |||
| 15 | 15 | ||
| 16 | 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 | 19 | 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 | |
| 27 | 21 | } | |
| 28 | 22 | ||
| 29 | 23 | // Config wires the visual process: fd 3 commands in, fd 4 events out. | |
| @@ -39,7 +33,7 @@ type Config struct { | |||
| 39 | 33 | func Run(ctx context.Context, cfg Config) error { | |
| 40 | 34 | s := &session{ | |
| 41 | 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 | 38 | return s.run(ctx) | |
| 45 | 39 | } | |
| @@ -215,7 +209,7 @@ func (s *session) startFade(ctx context.Context) *fadeRun { | |||
| 215 | 209 | return f | |
| 216 | 210 | } | |
| 217 | 211 | ||
| 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 | |
| 219 | 213 | // lock context is detached from process signals: it ends only with RunLock. | |
| 220 | 214 | func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun { | |
| 221 | 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 | 221 | go func() { defer close(l.authDone); ctl.run(lctx) }() | |
| 228 | 222 | 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)) }() | |
| 235 | 224 | return l | |
| 236 | 225 | } | |
| 237 | 226 | ||
| 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 | |
| 239 | 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 | 230 | 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) { | |
| 245 | 233 | lockView(f, st, promptKind(ctl.prompt.Load()), time.Now()) | |
| 246 | 234 | }, | |
| 247 | 235 | OnLocked: func() { | |
| @@ -255,6 +243,11 @@ func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.Secre | |||
| 255 | 243 | // black. Only the output name and the error are logged. | |
| 256 | 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 | 251 | OnSubmit: ctl.submit, | |
| 259 | 252 | Unlock: ctl.unlock, | |
| 260 | 253 | 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) error | 20 | + | 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 f | 209 | return f | ||
| 216 | } | 210 | } | ||
| 217 | 211 | ||||
| 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 |
| 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 l | 224 | return l | ||
| 236 | } | 225 | } | ||
| 237 | 226 | ||||
| 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 |
| 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/neferafk/internal/adapters/visualproc" | |
| 12 | 12 | "github.com/bnema/neferafk/internal/ports" | |
| 13 | - | "github.com/bnema/nefergui" | |
| 14 | 13 | "github.com/stretchr/testify/mock" | |
| 15 | 14 | "github.com/stretchr/testify/require" | |
| 16 | 15 | ) | |
| @@ -19,7 +18,7 @@ type harness struct { | |||
| 19 | 18 | cmdW *io.PipeWriter | |
| 20 | 19 | events *io.PipeReader | |
| 21 | 20 | done chan error | |
| 22 | - | cfgs chan nefergui.LockConfig | |
| 21 | + | cfgs chan lockConfig | |
| 23 | 22 | l *mockworkerLauncher | |
| 24 | 23 | lock *mocklockRunner | |
| 25 | 24 | fade *mockfadeRunner | |
| @@ -30,14 +29,14 @@ type harness struct { | |||
| 30 | 29 | func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) } | |
| 31 | 30 | ||
| 32 | 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 | 33 | t.Helper() | |
| 35 | 34 | cmdR, cmdW := io.Pipe() | |
| 36 | 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 | 37 | l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)} | |
| 39 | 38 | if runLock == nil { | |
| 40 | - | runLock = func(ctx context.Context, cfg nefergui.LockConfig) error { | |
| 39 | + | runLock = func(ctx context.Context, cfg lockConfig) error { | |
| 41 | 40 | h.cfgs <- cfg | |
| 42 | 41 | select { | |
| 43 | 42 | case <-cfg.Unlock: | |
| @@ -78,7 +77,7 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) { | |||
| 78 | 77 | cfg.OnSubmit([]byte("bad")) | |
| 79 | 78 | f := w.attempt(t) | |
| 80 | 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 | 81 | select { | |
| 83 | 82 | case err := <-h.done: | |
| 84 | 83 | t.Fatalf("session ended after denial: %v", err) | |
| @@ -93,9 +92,9 @@ func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) { | |||
| 93 | 92 | 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 | 96 | for { | |
| 98 | - | if s := recv(t, c); s == nefergui.LockFailed { | |
| 97 | + | if s := recv(t, c); s == lockFailed { | |
| 99 | 98 | return s | |
| 100 | 99 | } | |
| 101 | 100 | } | |
| @@ -195,7 +194,7 @@ func (h *harness) noEvent(t *testing.T) { | |||
| 195 | 194 | } | |
| 196 | 195 | ||
| 197 | 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 | 198 | w := expectWorker(t, h.l) | |
| 200 | 199 | h.sendLock(t) | |
| 201 | 200 | w.bootstrap(t) | |
| @@ -206,12 +205,12 @@ func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) { | |||
| 206 | 205 | ||
| 207 | 206 | func TestRunLockErrorNeverReleases(t *testing.T) { | |
| 208 | 207 | 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), | |
| 211 | 210 | "other": errors.New("boom"), | |
| 212 | 211 | } { | |
| 213 | 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 | 214 | w := expectWorker(t, h.l) | |
| 216 | 215 | h.sendLock(t) | |
| 217 | 216 | 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.PipeWriter | 18 | cmdW *io.PipeWriter | ||
| 20 | events *io.PipeReader | 19 | events *io.PipeReader | ||
| 21 | done chan error | 20 | done chan error | ||
| 22 | - | cfgs chan nefergui.LockConfig | 21 | + | cfgs chan lockConfig |
| 23 | l *mockworkerLauncher | 22 | l *mockworkerLauncher | ||
| 24 | lock *mocklockRunner | 23 | lock *mocklockRunner | ||
| 25 | fade *mockfadeRunner | 24 | 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 <- cfg | 40 | 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 lock | 92 | 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 s | 98 | 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 { | |||
| 69 | 69 | ||
| 70 | 70 | // hintFor is the line under the input: the attempt status first, then a note | |
| 71 | 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 | 73 | switch s { | |
| 74 | - | case nefergui.LockBusy: | |
| 74 | + | case lockBusy: | |
| 75 | 75 | return "..." | |
| 76 | - | case nefergui.LockFailed: | |
| 76 | + | case lockFailed: | |
| 77 | 77 | return "Denied" | |
| 78 | 78 | } | |
| 79 | 79 | switch p { | |
| @@ -89,7 +89,7 @@ func hintFor(s nefergui.LockStatus, p promptKind) string { | |||
| 89 | 89 | ||
| 90 | 90 | // maskFor is the field content and style: large dots for a PIN, bullets | |
| 91 | 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 | 93 | if p == promptPIN { | |
| 94 | 94 | 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 | 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 | 101 | frame := root.Column(nefergui.Class("frame"), nefergui.Inline(cssFrame)) | |
| 102 | 102 | frame.Text(now.Format(clockLayout), nefergui.Key("clock"), nefergui.Inline(cssClock)) | |
| 103 | 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 | 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 | 113 | UnlockBox(f.Root(nefergui.Class("screen"), nefergui.Inline(cssScreen)), s, p, now) | |
| 114 | 114 | } | |
| 115 | 115 | ||
| @@ -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 note | 70 | // 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, bullets | 90 | // 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)), cssMaskPIN | 94 | 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 ( | |||
| 5 | 5 | "time" | |
| 6 | 6 | "unicode/utf8" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/nefergui" | |
| 8 | + | "github.com/bnema/neferclient" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| @@ -43,31 +43,31 @@ func TestFadeKeepsTickingUntilEverySurfaceDrewTheEnd(t *testing.T) { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | 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) | |
| 51 | 51 | require.NotNil(t, l.InputRects) | |
| 52 | 52 | 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 | |
| 54 | 54 | require.Equal(t, all, l.Anchors) | |
| 55 | 55 | } | |
| 56 | 56 | ||
| 57 | 57 | func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) { | |
| 58 | 58 | for _, tc := range []struct { | |
| 59 | - | s nefergui.LockStatus | |
| 59 | + | s lockStatus | |
| 60 | 60 | p promptKind | |
| 61 | 61 | want string | |
| 62 | 62 | }{ | |
| 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, " "}, | |
| 71 | 71 | } { | |
| 72 | 72 | got := hintFor(tc.s, tc.p) | |
| 73 | 73 | require.Equal(t, tc.want, got) | |
| @@ -76,13 +76,13 @@ func TestHintsShowStatusThenPromptNotesAndFitTheBox(t *testing.T) { | |||
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | func TestMaskIsCappedToTheField(t *testing.T) { | |
| 79 | - | mask, _ := maskFor(nefergui.LockState{Mask: 3}, promptPIN) | |
| 79 | + | mask, _ := maskFor(lockState{Mask: 3}, promptPIN) | |
| 80 | 80 | require.Equal(t, "●●●", mask) | |
| 81 | - | mask, _ = maskFor(nefergui.LockState{Mask: 3}, promptFallback) | |
| 81 | + | mask, _ = maskFor(lockState{Mask: 3}, promptFallback) | |
| 82 | 82 | require.Equal(t, "•••_", mask) | |
| 83 | - | mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPIN) | |
| 83 | + | mask, _ = maskFor(lockState{Mask: 512}, promptPIN) | |
| 84 | 84 | require.Equal(t, maxPINMask, utf8.RuneCountInString(mask)) | |
| 85 | - | mask, _ = maskFor(nefergui.LockState{Mask: 512}, promptPassword) | |
| 85 | + | mask, _ = maskFor(lockState{Mask: 512}, promptPassword) | |
| 86 | 86 | require.Equal(t, maxPasswordMask+1, utf8.RuneCountInString(mask)) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| @@ -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.AnchorRight | 53 | + | 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.LockStatus | 59 | + | s lockStatus |
| 60 | p promptKind | 60 | p promptKind | ||
| 61 | want string | 61 | 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 ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/nefergui" | |
| 12 | 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 | 422 | // 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 | 424 | ret := _mock.Called(ctx, cfg) | |
| 426 | 425 | ||
| 427 | 426 | if len(ret) == 0 { | |
| @@ -429,7 +428,7 @@ func (_mock *mocklockRunner) RunLock(ctx context.Context, cfg nefergui.LockConfi | |||
| 429 | 428 | } | |
| 430 | 429 | ||
| 431 | 430 | 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 | 432 | r0 = returnFunc(ctx, cfg) | |
| 434 | 433 | } else { | |
| 435 | 434 | r0 = ret.Error(0) | |
| @@ -444,20 +443,20 @@ type mocklockRunner_RunLock_Call struct { | |||
| 444 | 443 | ||
| 445 | 444 | // RunLock is a helper method to define mock.On call | |
| 446 | 445 | // - ctx context.Context | |
| 447 | - | // - cfg nefergui.LockConfig | |
| 446 | + | // - cfg lockConfig | |
| 448 | 447 | func (_e *mocklockRunner_Expecter) RunLock(ctx any, cfg any) *mocklockRunner_RunLock_Call { | |
| 449 | 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 | 452 | _c.Call.Run(func(args mock.Arguments) { | |
| 454 | 453 | var arg0 context.Context | |
| 455 | 454 | if args[0] != nil { | |
| 456 | 455 | arg0 = args[0].(context.Context) | |
| 457 | 456 | } | |
| 458 | - | var arg1 nefergui.LockConfig | |
| 457 | + | var arg1 lockConfig | |
| 459 | 458 | if args[1] != nil { | |
| 460 | - | arg1 = args[1].(nefergui.LockConfig) | |
| 459 | + | arg1 = args[1].(lockConfig) | |
| 461 | 460 | } | |
| 462 | 461 | run( | |
| 463 | 462 | arg0, | |
| @@ -472,7 +471,7 @@ func (_c *mocklockRunner_RunLock_Call) Return(err error) *mocklockRunner_RunLock | |||
| 472 | 471 | 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 | 475 | _c.Call.Return(run) | |
| 477 | 476 | return _c | |
| 478 | 477 | } | |
| @@ -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 mocklockRunner | 422 | // 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 error | 430 | 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 call | 444 | // RunLock is a helper method to define mock.On call | ||
| 446 | // - ctx context.Context | 445 | // - ctx context.Context | ||
| 447 | - | // - cfg nefergui.LockConfig | 446 | + | // - 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.Context | 453 | 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.LockConfig | 457 | + | 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 _c | 471 | 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 _c | 476 | return _c | ||
| 478 | } | 477 | } | ||
M internal/adapters/visual/worker_test.go +1 -1
| @@ -29,7 +29,7 @@ func TestLockConfigReportsSkippedOutputWithComponent(t *testing.T) { | |||
| 29 | 29 | var buf bytes.Buffer | |
| 30 | 30 | log := zerowrap.New(zerowrap.Config{Output: &buf}) | |
| 31 | 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 | 33 | require.NotNil(t, cfg.OnOutputError) | |
| 34 | 34 | cfg.OnOutputError("HDMI-A-1", errors.New("surface failed")) | |
| 35 | 35 | out := buf.String() | |
| @@ -29,7 +29,7 @@ func TestLockConfigReportsSkippedOutputWithComponent(t *testing.T) { | |||||
| 29 | var buf bytes.Buffer | 29 | 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 ( | |||
| 11 | 11 | "sort" | |
| 12 | 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 | 17 | "github.com/bnema/neferafk/internal/ports" | |
| 15 | 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 | 19 | "github.com/bnema/zerowrap" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| @@ -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 | ||||