feat(lock): add secure session-lock API with secret input buffer
- commit
- 65d5b9b4c92b975510a8494f36976d499c876732
- author
- brice · · 2026-09-30 19:39 UTC
- committer
- brice · · 2026-09-30 19:49 UTC
- parent
- 6e0cddc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +3322 -51
M README.md +1 -0
| @@ -57,6 +57,7 @@ CGO_ENABLED=0 go run ./examples/demo | |||
| 57 | 57 | - [CSS properties and limitations](docs/css.md) | |
| 58 | 58 | - [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md) | |
| 59 | 59 | - [Visual test harness](docs/harness.md) and [performance](docs/performance.md) | |
| 60 | + | - [Session lock client](docs/session-lock.md) | |
| 60 | 61 | - [Architecture decisions](docs/adr) | |
| 61 | 62 | ||
| 62 | 63 | ## License | |
| @@ -57,6 +57,7 @@ CGO_ENABLED=0 go run ./examples/demo | |||||
| 57 | - [CSS properties and limitations](docs/css.md) | 57 | - [CSS properties and limitations](docs/css.md) | ||
| 58 | - [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md) | 58 | - [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md) | ||
| 59 | - [Visual test harness](docs/harness.md) and [performance](docs/performance.md) | 59 | - [Visual test harness](docs/harness.md) and [performance](docs/performance.md) | ||
| 60 | + | - [Session lock client](docs/session-lock.md) | |||
| 60 | - [Architecture decisions](docs/adr) | 61 | - [Architecture decisions](docs/adr) | ||
| 61 | 62 | ||||
| 62 | ## License | 63 | ## License | ||
A docs/session-lock.md +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | # Session lock | |
| 2 | + | ||
| 3 | + | `nefergui.RunLock` implements the client side of `ext-session-lock-v1`. It acquires the lock, covers every output with opaque black and hosts the caller's view on one output. Authentication is the caller's; NeferGUI never unlocks on its own. | |
| 4 | + | ||
| 5 | + | ## Behavior | |
| 6 | + | ||
| 7 | + | - One Wayland connection, seat and reader serve every lock surface. One lock surface is created per output present at acquisition; outputs added later get a black surface, removed outputs lose theirs. | |
| 8 | + | - Rendering uses the normal Vulkan, DMA-BUF and explicit-sync path only: no shared-memory buffer, no readback, no implicit-sync fallback. A surface allocates and attaches a buffer only after its configure was acknowledged and its dmabuf feedback round completed (`Window.FeedbackDone`). | |
| 9 | + | - Exactly one output hosts the view: the one whose surface has keyboard focus, initially the first. Every other output shows plain black (an empty frame). | |
| 10 | + | - `OnLocked` fires when the compositor sends `locked`. `finished` (refused or ended) makes `RunLock` return `ErrLockFinished`; the lock is then destroyed with a plain `destroy`, never an unlock. | |
| 11 | + | - Verification is the caller's and asynchronous: `OnSubmit` never blocks and never unlocks. Unlock happens only when the caller sends on `LockConfig.Unlock` (after its own verification); a request made before `locked` is held until `locked`, closing the channel is not a request. Then: `unlock_and_destroy`, a display roundtrip, and the connection closes. The caller shows progress with `LockConfig.Status` (`LockIdle`, `LockBusy`, `LockFailed`), surfaced as `LockState.Status`. Cancellation or any error closes the connection without unlocking, which keeps the session locked. | |
| 12 | + | ||
| 13 | + | ## Configuration | |
| 14 | + | ||
| 15 | + | - `Display` is the Wayland socket name; empty uses the environment. | |
| 16 | + | - `Wake` requests a redraw for each value received. Its forwarder is scoped to `RunLock` and stops when it returns. | |
| 17 | + | - `OnOutputError(output, err)` reports, with the connector name, an output added after acquisition that could not get a lock surface or render path. Such hotplug failures are skipped (the compositor keeps the output black); failures on outputs present at acquisition abort `RunLock`. | |
| 18 | + | - `NewSecretBuffer` panics unless the capacity is between 1 and 4096 bytes. `RunLock` wipes `Secret` when it returns, and the buffer is wiped after `OnSubmit` even if it panics. | |
| 19 | + | - Outputs are promoted in ascending global order, so the first host is deterministic. | |
| 20 | + | - Keyboard focus-out drops held keys and ignores key presses until focus returns; typed bytes stay in `Secret`, and input continues when any lock surface regains focus (that surface becomes the host). | |
| 21 | + | ||
| 22 | + | ## Secret input | |
| 23 | + | ||
| 24 | + | Typed keys go through the keyboard secret path (`EventSecret`/`TickSecret`) into a caller-owned `SecretBuffer`. They never become a Go string, and never enter the model, the frame tree, render text, IME, pre-edit or the clipboard (lock surfaces bind neither). The view receives `LockState.Mask`, the number of typed code points, and draws bullets. | |
| 25 | + | ||
| 26 | + | Enter calls `OnSubmit` (after `locked` only; the buffer is wiped right after it returns, so it must copy or consume the slice and must not block), Escape and Ctrl+U clear, Backspace removes the last code point. Before `locked`, Enter only clears. Scratch buffers are zeroed after every key. | |
| 27 | + | ||
| 28 | + | ## Example | |
| 29 | + | ||
| 30 | + | ```go | |
| 31 | + | secret := nefergui.NewSecretBuffer(256) | |
| 32 | + | defer secret.Wipe() | |
| 33 | + | err := nefergui.RunLock(ctx, nefergui.LockConfig{ | |
| 34 | + | Secret: secret, | |
| 35 | + | Styles: "lock.css", | |
| 36 | + | View: func(f *nefergui.Frame, s nefergui.LockState) { | |
| 37 | + | box := f.Root(nefergui.Class("screen")).Column(nefergui.Class("box")) | |
| 38 | + | box.Text(s.Bullets(), nefergui.Key("mask")) | |
| 39 | + | }, | |
| 40 | + | Unlock: unlock, // chan struct{}: send after verification succeeded | |
| 41 | + | Status: status, // chan nefergui.LockStatus: LockBusy / LockFailed / LockIdle | |
| 42 | + | OnSubmit: func(pw []byte) { // copy or consume pw now; it is wiped on return | |
| 43 | + | go verify(bytes.Clone(pw), unlock, status) | |
| 44 | + | }, | |
| 45 | + | }) | |
| 46 | + | ``` | |
| 47 | + | ||
| 48 | + | ## Verification | |
| 49 | + | ||
| 50 | + | Wire tests cover the shared connection (reader pause and queue saturation, keyboard and pointer generations, feedback completion). `NEFERGUI_HARNESS=1` with a NeferWL binary in `NEFERGUI_NEFERWL` runs the headless lock tests (`TestHarnessConnectionLock`, `TestHarnessRunLock`, `TestHarnessRunLockRefused`). | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | # Session lock | |||
| 2 | + | ||||
| 3 | + | `nefergui.RunLock` implements the client side of `ext-session-lock-v1`. It acquires the lock, covers every output with opaque black and hosts the caller's view on one output. Authentication is the caller's; NeferGUI never unlocks on its own. | |||
| 4 | + | ||||
| 5 | + | ## Behavior | |||
| 6 | + | ||||
| 7 | + | - One Wayland connection, seat and reader serve every lock surface. One lock surface is created per output present at acquisition; outputs added later get a black surface, removed outputs lose theirs. | |||
| 8 | + | - Rendering uses the normal Vulkan, DMA-BUF and explicit-sync path only: no shared-memory buffer, no readback, no implicit-sync fallback. A surface allocates and attaches a buffer only after its configure was acknowledged and its dmabuf feedback round completed (`Window.FeedbackDone`). | |||
| 9 | + | - Exactly one output hosts the view: the one whose surface has keyboard focus, initially the first. Every other output shows plain black (an empty frame). | |||
| 10 | + | - `OnLocked` fires when the compositor sends `locked`. `finished` (refused or ended) makes `RunLock` return `ErrLockFinished`; the lock is then destroyed with a plain `destroy`, never an unlock. | |||
| 11 | + | - Verification is the caller's and asynchronous: `OnSubmit` never blocks and never unlocks. Unlock happens only when the caller sends on `LockConfig.Unlock` (after its own verification); a request made before `locked` is held until `locked`, closing the channel is not a request. Then: `unlock_and_destroy`, a display roundtrip, and the connection closes. The caller shows progress with `LockConfig.Status` (`LockIdle`, `LockBusy`, `LockFailed`), surfaced as `LockState.Status`. Cancellation or any error closes the connection without unlocking, which keeps the session locked. | |||
| 12 | + | ||||
| 13 | + | ## Configuration | |||
| 14 | + | ||||
| 15 | + | - `Display` is the Wayland socket name; empty uses the environment. | |||
| 16 | + | - `Wake` requests a redraw for each value received. Its forwarder is scoped to `RunLock` and stops when it returns. | |||
| 17 | + | - `OnOutputError(output, err)` reports, with the connector name, an output added after acquisition that could not get a lock surface or render path. Such hotplug failures are skipped (the compositor keeps the output black); failures on outputs present at acquisition abort `RunLock`. | |||
| 18 | + | - `NewSecretBuffer` panics unless the capacity is between 1 and 4096 bytes. `RunLock` wipes `Secret` when it returns, and the buffer is wiped after `OnSubmit` even if it panics. | |||
| 19 | + | - Outputs are promoted in ascending global order, so the first host is deterministic. | |||
| 20 | + | - Keyboard focus-out drops held keys and ignores key presses until focus returns; typed bytes stay in `Secret`, and input continues when any lock surface regains focus (that surface becomes the host). | |||
| 21 | + | ||||
| 22 | + | ## Secret input | |||
| 23 | + | ||||
| 24 | + | Typed keys go through the keyboard secret path (`EventSecret`/`TickSecret`) into a caller-owned `SecretBuffer`. They never become a Go string, and never enter the model, the frame tree, render text, IME, pre-edit or the clipboard (lock surfaces bind neither). The view receives `LockState.Mask`, the number of typed code points, and draws bullets. | |||
| 25 | + | ||||
| 26 | + | Enter calls `OnSubmit` (after `locked` only; the buffer is wiped right after it returns, so it must copy or consume the slice and must not block), Escape and Ctrl+U clear, Backspace removes the last code point. Before `locked`, Enter only clears. Scratch buffers are zeroed after every key. | |||
| 27 | + | ||||
| 28 | + | ## Example | |||
| 29 | + | ||||
| 30 | + | ```go | |||
| 31 | + | secret := nefergui.NewSecretBuffer(256) | |||
| 32 | + | defer secret.Wipe() | |||
| 33 | + | err := nefergui.RunLock(ctx, nefergui.LockConfig{ | |||
| 34 | + | Secret: secret, | |||
| 35 | + | Styles: "lock.css", | |||
| 36 | + | View: func(f *nefergui.Frame, s nefergui.LockState) { | |||
| 37 | + | box := f.Root(nefergui.Class("screen")).Column(nefergui.Class("box")) | |||
| 38 | + | box.Text(s.Bullets(), nefergui.Key("mask")) | |||
| 39 | + | }, | |||
| 40 | + | Unlock: unlock, // chan struct{}: send after verification succeeded | |||
| 41 | + | Status: status, // chan nefergui.LockStatus: LockBusy / LockFailed / LockIdle | |||
| 42 | + | OnSubmit: func(pw []byte) { // copy or consume pw now; it is wiped on return | |||
| 43 | + | go verify(bytes.Clone(pw), unlock, status) | |||
| 44 | + | }, | |||
| 45 | + | }) | |||
| 46 | + | ``` | |||
| 47 | + | ||||
| 48 | + | ## Verification | |||
| 49 | + | ||||
| 50 | + | Wire tests cover the shared connection (reader pause and queue saturation, keyboard and pointer generations, feedback completion). `NEFERGUI_HARNESS=1` with a NeferWL binary in `NEFERGUI_NEFERWL` runs the headless lock tests (`TestHarnessConnectionLock`, `TestHarnessRunLock`, `TestHarnessRunLockRefused`). | |||
M go.mod +8 -0
| @@ -13,3 +13,11 @@ require ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | require golang.org/x/text v0.42.0 // indirect | |
| 16 | + | ||
| 17 | + | replace github.com/bnema/wlturbo => ../../../wlturbo/.worktrees/session-lock | |
| 18 | + | ||
| 19 | + | replace github.com/bnema/purego-xkbcommon => ../../../purego-xkbcommon/.worktrees/secret-bytes | |
| 20 | + | ||
| 21 | + | replace github.com/bnema/purego => ../../../purego | |
| 22 | + | ||
| 23 | + | replace github.com/bnema/purego-vulkan => ../../../purego-vulkan | |
| @@ -13,3 +13,11 @@ require ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | require golang.org/x/text v0.42.0 // indirect | 15 | require golang.org/x/text v0.42.0 // indirect | ||
| 16 | + | ||||
| 17 | + | replace github.com/bnema/wlturbo => ../../../wlturbo/.worktrees/session-lock | |||
| 18 | + | ||||
| 19 | + | replace github.com/bnema/purego-xkbcommon => ../../../purego-xkbcommon/.worktrees/secret-bytes | |||
| 20 | + | ||||
| 21 | + | replace github.com/bnema/purego => ../../../purego | |||
| 22 | + | ||||
| 23 | + | replace github.com/bnema/purego-vulkan => ../../../purego-vulkan | |||
M go.sum +0 -8
| @@ -1,11 +1,3 @@ | |||
| 1 | - | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |
| 2 | - | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 3 | - | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |
| 4 | - | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 5 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 h1:XePqfzZzlT1WEQ8G9atwlXjW5jFZAkEoUGnlYvsHAHg= | |
| 6 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg= | |
| 7 | - | github.com/bnema/wlturbo v0.3.0 h1:Xt5HoiTL/pTVnUjD29mzBsUAVJp+1bxB9DfzxZK6RVA= | |
| 8 | - | github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 9 | 1 | github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= | |
| 10 | 2 | github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= | |
| 11 | 3 | github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= | |
| @@ -1,11 +1,3 @@ | |||||
| 1 | - | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |||
| 2 | - | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |||
| 3 | - | github.com/bnema/purego-vulkan v0.6.0 h1:SJgu0smuDC2vtBv0dJ6dmRKewJW5EUXQwTXyb/p9JSE= | |||
| 4 | - | github.com/bnema/purego-vulkan v0.6.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |||
| 5 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2 h1:XePqfzZzlT1WEQ8G9atwlXjW5jFZAkEoUGnlYvsHAHg= | |||
| 6 | - | github.com/bnema/purego-xkbcommon v0.0.0-20260928071113-e79265dfcdc2/go.mod h1:GE8JGmR/rqdklz96tFaFU7M5kI7jN1ma5mh+rz+XXrg= | |||
| 7 | - | github.com/bnema/wlturbo v0.3.0 h1:Xt5HoiTL/pTVnUjD29mzBsUAVJp+1bxB9DfzxZK6RVA= | |||
| 8 | - | github.com/bnema/wlturbo v0.3.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |||
| 9 | github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= | 1 | github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= | ||
| 10 | github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= | 2 | github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= | ||
| 11 | github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= | 3 | github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= | ||
M internal/keyboard/keyboard.go +9 -1
| @@ -24,6 +24,8 @@ type Keyboard struct { | |||
| 24 | 24 | state *xkb.State | |
| 25 | 25 | compose *xkb.ComposeState | |
| 26 | 26 | held map[uint32]Key | |
| 27 | + | secretHeld map[uint32]SecretKey | |
| 28 | + | secretMode bool | |
| 27 | 29 | rate int | |
| 28 | 30 | delay time.Duration | |
| 29 | 31 | next time.Time | |
| @@ -120,7 +122,10 @@ func (k *Keyboard) Mask(depressed, latched, locked, groupDepressed, groupLatched | |||
| 120 | 122 | func (k *Keyboard) Focus(on bool) { | |
| 121 | 123 | k.focus = on | |
| 122 | 124 | k.held = make(map[uint32]Key) | |
| 125 | + | clear(k.secretHeld) | |
| 126 | + | k.secretMode = false | |
| 123 | 127 | k.repeating = 0 | |
| 128 | + | k.next = time.Time{} | |
| 124 | 129 | if k.compose != nil { | |
| 125 | 130 | k.compose.Reset() | |
| 126 | 131 | } | |
| @@ -134,6 +139,9 @@ func (k *Keyboard) RepeatInfo(rate int, delay time.Duration) { | |||
| 134 | 139 | } | |
| 135 | 140 | } | |
| 136 | 141 | func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) { | |
| 142 | + | if k.secretMode { | |
| 143 | + | k.Focus(k.focus) | |
| 144 | + | } | |
| 137 | 145 | if !pressed { | |
| 138 | 146 | key := k.held[evdev] | |
| 139 | 147 | delete(k.held, evdev) | |
| @@ -210,7 +218,7 @@ func repeatable(sym uint32) bool { | |||
| 210 | 218 | // Compose is fed only by physical presses: synthetic repeats neither advance a | |
| 211 | 219 | // compose sequence nor replay the press-time composed text. | |
| 212 | 220 | func (k *Keyboard) Tick(now time.Time) (Key, bool) { | |
| 213 | - | if !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | |
| 221 | + | if k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | |
| 214 | 222 | return Key{}, false | |
| 215 | 223 | } | |
| 216 | 224 | physical := k.repeating | |
| @@ -24,6 +24,8 @@ type Keyboard struct { | |||||
| 24 | state *xkb.State | 24 | state *xkb.State | ||
| 25 | compose *xkb.ComposeState | 25 | compose *xkb.ComposeState | ||
| 26 | held map[uint32]Key | 26 | held map[uint32]Key | ||
| 27 | + | secretHeld map[uint32]SecretKey | |||
| 28 | + | secretMode bool | |||
| 27 | rate int | 29 | rate int | ||
| 28 | delay time.Duration | 30 | delay time.Duration | ||
| 29 | next time.Time | 31 | next time.Time | ||
| @@ -120,7 +122,10 @@ func (k *Keyboard) Mask(depressed, latched, locked, groupDepressed, groupLatched | |||||
| 120 | func (k *Keyboard) Focus(on bool) { | 122 | func (k *Keyboard) Focus(on bool) { | ||
| 121 | k.focus = on | 123 | k.focus = on | ||
| 122 | k.held = make(map[uint32]Key) | 124 | k.held = make(map[uint32]Key) | ||
| 125 | + | clear(k.secretHeld) | |||
| 126 | + | k.secretMode = false | |||
| 123 | k.repeating = 0 | 127 | k.repeating = 0 | ||
| 128 | + | k.next = time.Time{} | |||
| 124 | if k.compose != nil { | 129 | if k.compose != nil { | ||
| 125 | k.compose.Reset() | 130 | k.compose.Reset() | ||
| 126 | } | 131 | } | ||
| @@ -134,6 +139,9 @@ func (k *Keyboard) RepeatInfo(rate int, delay time.Duration) { | |||||
| 134 | } | 139 | } | ||
| 135 | } | 140 | } | ||
| 136 | func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) { | 141 | func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) { | ||
| 142 | + | if k.secretMode { | |||
| 143 | + | k.Focus(k.focus) | |||
| 144 | + | } | |||
| 137 | if !pressed { | 145 | if !pressed { | ||
| 138 | key := k.held[evdev] | 146 | key := k.held[evdev] | ||
| 139 | delete(k.held, evdev) | 147 | delete(k.held, evdev) | ||
| @@ -210,7 +218,7 @@ func repeatable(sym uint32) bool { | |||||
| 210 | // Compose is fed only by physical presses: synthetic repeats neither advance a | 218 | // Compose is fed only by physical presses: synthetic repeats neither advance a | ||
| 211 | // compose sequence nor replay the press-time composed text. | 219 | // compose sequence nor replay the press-time composed text. | ||
| 212 | func (k *Keyboard) Tick(now time.Time) (Key, bool) { | 220 | func (k *Keyboard) Tick(now time.Time) (Key, bool) { | ||
| 213 | - | if !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | 221 | + | if k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { |
| 214 | return Key{}, false | 222 | return Key{}, false | ||
| 215 | } | 223 | } | ||
| 216 | physical := k.repeating | 224 | physical := k.repeating | ||
M internal/keyboard/keyboard_test.go +22 -6
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package keyboard | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "os" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/purego-xkbcommon/raw" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func TestLayoutsAndRepeat(t *testing.T) { | |
| @@ -102,14 +104,22 @@ func TestKeymapFDReplacement(t *testing.T) { | |||
| 102 | 104 | if err != nil { | |
| 103 | 105 | t.Fatal(err) | |
| 104 | 106 | } | |
| 107 | + | defer f.Close() | |
| 105 | 108 | if _, err = f.WriteString("bad keymap"); err != nil { | |
| 106 | 109 | t.Fatal(err) | |
| 107 | 110 | } | |
| 108 | - | if err = k.ReplaceFD(int(f.Fd()), len("bad keymap")); err == nil { | |
| 111 | + | // Only transfer a raw duplicate. The original os.File remains its sole | |
| 112 | + | // owner's responsibility, so its finalizer cannot close a reused FD. | |
| 113 | + | fd, err := unix.Dup(int(f.Fd())) | |
| 114 | + | if err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if err = k.ReplaceFD(fd, len("bad keymap")); err == nil { | |
| 109 | 118 | t.Fatal("bad keymap accepted") | |
| 110 | 119 | } | |
| 111 | - | if _, err = f.Stat(); err == nil { | |
| 112 | - | t.Fatal("keymap FD not closed") | |
| 120 | + | var stat unix.Stat_t | |
| 121 | + | if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) { | |
| 122 | + | t.Fatalf("keymap duplicate not consumed: %v", err) | |
| 113 | 123 | } | |
| 114 | 124 | if err = k.ReplaceFD(-1, 0); err == nil { | |
| 115 | 125 | t.Fatal("invalid fd accepted") | |
| @@ -141,14 +151,20 @@ func TestValidFDReplacementCancelsRepeat(t *testing.T) { | |||
| 141 | 151 | if err != nil { | |
| 142 | 152 | t.Fatal(err) | |
| 143 | 153 | } | |
| 154 | + | defer file.Close() | |
| 144 | 155 | if _, err = file.Write(append(data, 0)); err != nil { | |
| 145 | 156 | t.Fatal(err) | |
| 146 | 157 | } | |
| 147 | - | if err = k.ReplaceFD(int(file.Fd()), len(data)+1); err != nil { | |
| 158 | + | fd, err := unix.Dup(int(file.Fd())) | |
| 159 | + | if err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | if err = k.ReplaceFD(fd, len(data)+1); err != nil { | |
| 148 | 163 | t.Fatal(err) | |
| 149 | 164 | } | |
| 150 | - | if _, err = file.Stat(); err == nil { | |
| 151 | - | t.Fatal("FD not consumed") | |
| 165 | + | var stat unix.Stat_t | |
| 166 | + | if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) { | |
| 167 | + | t.Fatalf("keymap duplicate not consumed: %v", err) | |
| 152 | 168 | } | |
| 153 | 169 | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 154 | 170 | t.Fatal("repeat survived keymap replacement") | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | package keyboard | 1 | package keyboard | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/purego-xkbcommon/raw" | 9 | "github.com/bnema/purego-xkbcommon/raw" | ||
| 10 | + | "golang.org/x/sys/unix" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | func TestLayoutsAndRepeat(t *testing.T) { | 13 | func TestLayoutsAndRepeat(t *testing.T) { | ||
| @@ -102,14 +104,22 @@ func TestKeymapFDReplacement(t *testing.T) { | |||||
| 102 | if err != nil { | 104 | if err != nil { | ||
| 103 | t.Fatal(err) | 105 | t.Fatal(err) | ||
| 104 | } | 106 | } | ||
| 107 | + | defer f.Close() | |||
| 105 | if _, err = f.WriteString("bad keymap"); err != nil { | 108 | if _, err = f.WriteString("bad keymap"); err != nil { | ||
| 106 | t.Fatal(err) | 109 | t.Fatal(err) | ||
| 107 | } | 110 | } | ||
| 108 | - | if err = k.ReplaceFD(int(f.Fd()), len("bad keymap")); err == nil { | 111 | + | // Only transfer a raw duplicate. The original os.File remains its sole |
| 112 | + | // owner's responsibility, so its finalizer cannot close a reused FD. | |||
| 113 | + | fd, err := unix.Dup(int(f.Fd())) | |||
| 114 | + | if err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | if err = k.ReplaceFD(fd, len("bad keymap")); err == nil { | |||
| 109 | t.Fatal("bad keymap accepted") | 118 | t.Fatal("bad keymap accepted") | ||
| 110 | } | 119 | } | ||
| 111 | - | if _, err = f.Stat(); err == nil { | 120 | + | var stat unix.Stat_t |
| 112 | - | t.Fatal("keymap FD not closed") | 121 | + | if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) { |
| 122 | + | t.Fatalf("keymap duplicate not consumed: %v", err) | |||
| 113 | } | 123 | } | ||
| 114 | if err = k.ReplaceFD(-1, 0); err == nil { | 124 | if err = k.ReplaceFD(-1, 0); err == nil { | ||
| 115 | t.Fatal("invalid fd accepted") | 125 | t.Fatal("invalid fd accepted") | ||
| @@ -141,14 +151,20 @@ func TestValidFDReplacementCancelsRepeat(t *testing.T) { | |||||
| 141 | if err != nil { | 151 | if err != nil { | ||
| 142 | t.Fatal(err) | 152 | t.Fatal(err) | ||
| 143 | } | 153 | } | ||
| 154 | + | defer file.Close() | |||
| 144 | if _, err = file.Write(append(data, 0)); err != nil { | 155 | if _, err = file.Write(append(data, 0)); err != nil { | ||
| 145 | t.Fatal(err) | 156 | t.Fatal(err) | ||
| 146 | } | 157 | } | ||
| 147 | - | if err = k.ReplaceFD(int(file.Fd()), len(data)+1); err != nil { | 158 | + | fd, err := unix.Dup(int(file.Fd())) |
| 159 | + | if err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | if err = k.ReplaceFD(fd, len(data)+1); err != nil { | |||
| 148 | t.Fatal(err) | 163 | t.Fatal(err) | ||
| 149 | } | 164 | } | ||
| 150 | - | if _, err = file.Stat(); err == nil { | 165 | + | var stat unix.Stat_t |
| 151 | - | t.Fatal("FD not consumed") | 166 | + | if err = unix.Fstat(fd, &stat); !errors.Is(err, unix.EBADF) { |
| 167 | + | t.Fatalf("keymap duplicate not consumed: %v", err) | |||
| 152 | } | 168 | } | ||
| 153 | if _, ok := k.Tick(now.Add(time.Second)); ok { | 169 | if _, ok := k.Tick(now.Add(time.Second)); ok { | ||
| 154 | t.Fatal("repeat survived keymap replacement") | 170 | t.Fatal("repeat survived keymap replacement") | ||
A internal/keyboard/secret.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | package keyboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "time" | |
| 6 | + | "unicode/utf8" | |
| 7 | + | ||
| 8 | + | xkb "github.com/bnema/purego-xkbcommon" | |
| 9 | + | "github.com/bnema/purego-xkbcommon/raw" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SecretKind is the limited editing vocabulary accepted by secret consumers. | |
| 13 | + | type SecretKind uint8 | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | SecretNone SecretKind = iota | |
| 17 | + | SecretBytes | |
| 18 | + | SecretBackspace | |
| 19 | + | SecretReturn | |
| 20 | + | SecretEscape | |
| 21 | + | SecretClear // Control+U | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // SecretKey contains scalar metadata only, never names or text. Bytes are | |
| 25 | + | // supplied separately and exclusively within EventSecret/TickSecret callbacks. | |
| 26 | + | type SecretKey struct { | |
| 27 | + | Physical, Logical uint32 | |
| 28 | + | Kind SecretKind | |
| 29 | + | Pressed, Repeat bool | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | var errSecretCallback = errors.New("keyboard: secret callback is nil") | |
| 33 | + | ||
| 34 | + | // ClearSecret cancels secret-held keys, repeat and local compose. It retains | |
| 35 | + | // the compositor's current keymap and modifier mask. No text is cached here. | |
| 36 | + | func (k *Keyboard) ClearSecret() { | |
| 37 | + | clear(k.secretHeld) | |
| 38 | + | clear(k.held) // fail-closed even when secret entry fails from ordinary mode | |
| 39 | + | k.repeating = 0 | |
| 40 | + | k.next = time.Time{} | |
| 41 | + | k.composePending = false | |
| 42 | + | if k.compose != nil { | |
| 43 | + | _ = k.compose.Reset() | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // EventSecret interprets a physical event without calling any string-producing | |
| 48 | + | // XKB API or populating Key/held. scratch length must be 1..512, including space | |
| 49 | + | // for native NUL (max payload 511). The callback is synchronous and must not | |
| 50 | + | // retain bytes or convert them to strings. scratch is valid only for that call; | |
| 51 | + | // this method clears it on entry and every exit, in addition to caller wiping. | |
| 52 | + | // Enter, Escape and Control+U are initial-press controls, never repeats. | |
| 53 | + | // Release callbacks carry no bytes and Pressed=false; consumers must act on | |
| 54 | + | // controls only when Pressed=true. Switching APIs cancels the other path. | |
| 55 | + | func (k *Keyboard) EventSecret(evdev uint32, pressed bool, now time.Time, scratch []byte, consume func(SecretKey, []byte)) error { | |
| 56 | + | clear(scratch) | |
| 57 | + | defer clear(scratch) | |
| 58 | + | if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer { | |
| 59 | + | k.ClearSecret() | |
| 60 | + | return xkb.ErrUTF8Buffer | |
| 61 | + | } | |
| 62 | + | if consume == nil { | |
| 63 | + | k.ClearSecret() | |
| 64 | + | return errSecretCallback | |
| 65 | + | } | |
| 66 | + | if !k.secretMode { | |
| 67 | + | k.Focus(k.focus) // discard ordinary held text and compose on mode entry | |
| 68 | + | k.secretMode = true | |
| 69 | + | if k.secretHeld == nil { | |
| 70 | + | k.secretHeld = make(map[uint32]SecretKey) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | if !pressed { | |
| 74 | + | key, ok := k.secretHeld[evdev] | |
| 75 | + | delete(k.secretHeld, evdev) | |
| 76 | + | if k.repeating == evdev { | |
| 77 | + | k.repeating = 0 | |
| 78 | + | } | |
| 79 | + | if ok && key.Kind != SecretNone { | |
| 80 | + | key.Pressed = false | |
| 81 | + | consume(key, nil) | |
| 82 | + | } | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | if !k.focus || k.state == nil { | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | _, repeat := k.secretHeld[evdev] | |
| 89 | + | key, n, err := k.secretKey(evdev, repeat, scratch) | |
| 90 | + | if err != nil { | |
| 91 | + | k.ClearSecret() | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | k.secretHeld[evdev] = key | |
| 95 | + | if key.Kind == SecretReturn || key.Kind == SecretEscape || key.Kind == SecretClear { | |
| 96 | + | // Clear/submit/cancel must stop a previously held printable repeat | |
| 97 | + | // before the consumer runs; releasing Control must not resurrect it. | |
| 98 | + | k.repeating = 0 | |
| 99 | + | k.next = time.Time{} | |
| 100 | + | } | |
| 101 | + | if k.rate > 0 && secretRepeatable(key) { | |
| 102 | + | k.repeating = evdev | |
| 103 | + | k.next = now.Add(k.delay) | |
| 104 | + | } | |
| 105 | + | if key.Kind != SecretNone { | |
| 106 | + | consume(key, scratch[:n:n]) | |
| 107 | + | } | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func secretRepeatable(key SecretKey) bool { | |
| 112 | + | return key.Kind == SecretBackspace || key.Kind == SecretBytes && repeatable(key.Logical) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (k *Keyboard) secretKey(evdev uint32, repeat bool, scratch []byte) (SecretKey, int, error) { | |
| 116 | + | code := xkb.WaylandKeycode(evdev) | |
| 117 | + | sym, err := k.state.KeySym(code) | |
| 118 | + | if err != nil { | |
| 119 | + | return SecretKey{}, 0, err | |
| 120 | + | } | |
| 121 | + | key := SecretKey{Physical: evdev, Logical: sym, Pressed: true, Repeat: repeat} | |
| 122 | + | _, ctrl, err := k.modifiers() | |
| 123 | + | if err != nil { | |
| 124 | + | return SecretKey{}, 0, err | |
| 125 | + | } | |
| 126 | + | switch { | |
| 127 | + | case sym == raw.XKB_KEY_BackSpace: | |
| 128 | + | key.Kind = SecretBackspace | |
| 129 | + | case sym == raw.XKB_KEY_Return || sym == raw.XKB_KEY_KP_Enter: | |
| 130 | + | key.Kind = SecretReturn | |
| 131 | + | case sym == raw.XKB_KEY_Escape: | |
| 132 | + | key.Kind = SecretEscape | |
| 133 | + | case ctrl && (sym == raw.XKB_KEY_u || sym == raw.XKB_KEY_U): | |
| 134 | + | key.Kind = SecretClear | |
| 135 | + | } | |
| 136 | + | if key.Kind != SecretNone { | |
| 137 | + | if k.compose != nil { | |
| 138 | + | _ = k.compose.Reset() | |
| 139 | + | } | |
| 140 | + | k.composePending = false | |
| 141 | + | if repeat && key.Kind != SecretBackspace { | |
| 142 | + | key.Kind = SecretNone | |
| 143 | + | } | |
| 144 | + | return key, 0, nil | |
| 145 | + | } | |
| 146 | + | // Other Control combinations are not secret bytes or application shortcuts. | |
| 147 | + | if ctrl { | |
| 148 | + | return key, 0, nil | |
| 149 | + | } | |
| 150 | + | if k.compose != nil && !repeat { | |
| 151 | + | if _, err := k.compose.Feed(sym); err != nil { | |
| 152 | + | return key, 0, err | |
| 153 | + | } | |
| 154 | + | status, err := k.compose.Status() | |
| 155 | + | if err != nil { | |
| 156 | + | return key, 0, err | |
| 157 | + | } | |
| 158 | + | switch status { | |
| 159 | + | case raw.XKB_COMPOSE_COMPOSING: | |
| 160 | + | k.composePending = true | |
| 161 | + | return key, 0, nil | |
| 162 | + | case raw.XKB_COMPOSE_CANCELLED: | |
| 163 | + | _ = k.compose.Reset() | |
| 164 | + | k.composePending = false | |
| 165 | + | return key, 0, nil | |
| 166 | + | case raw.XKB_COMPOSE_COMPOSED: | |
| 167 | + | n, err := k.compose.UTF8Into(scratch) | |
| 168 | + | _ = k.compose.Reset() | |
| 169 | + | k.composePending = false | |
| 170 | + | if err != nil { | |
| 171 | + | return key, 0, err | |
| 172 | + | } | |
| 173 | + | return classifySecretBytes(key, scratch, n), n, nil | |
| 174 | + | default: | |
| 175 | + | k.composePending = false | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | if k.composePending { | |
| 179 | + | return key, 0, nil | |
| 180 | + | } | |
| 181 | + | n, err := k.state.UTF8Into(code, scratch) | |
| 182 | + | if err != nil { | |
| 183 | + | return key, 0, err | |
| 184 | + | } | |
| 185 | + | return classifySecretBytes(key, scratch, n), n, nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func classifySecretBytes(key SecretKey, scratch []byte, n int) SecretKey { | |
| 189 | + | if n == 0 || !utf8.Valid(scratch[:n]) { | |
| 190 | + | return key | |
| 191 | + | } | |
| 192 | + | for rest := scratch[:n]; len(rest) > 0; { | |
| 193 | + | r, size := utf8.DecodeRune(rest) | |
| 194 | + | if r < 0x20 || r == 0x7f { | |
| 195 | + | return key | |
| 196 | + | } | |
| 197 | + | rest = rest[size:] | |
| 198 | + | } | |
| 199 | + | key.Kind = SecretBytes | |
| 200 | + | return key | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // TickSecret resolves repeat bytes using the current mask/group, not press-time | |
| 204 | + | // plaintext. Compose is never fed by repeats. It reports whether a callback ran. | |
| 205 | + | func (k *Keyboard) TickSecret(now time.Time, scratch []byte, consume func(SecretKey, []byte)) (bool, error) { | |
| 206 | + | clear(scratch) | |
| 207 | + | defer clear(scratch) | |
| 208 | + | if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer { | |
| 209 | + | k.ClearSecret() | |
| 210 | + | return false, xkb.ErrUTF8Buffer | |
| 211 | + | } | |
| 212 | + | if consume == nil { | |
| 213 | + | k.ClearSecret() | |
| 214 | + | return false, errSecretCallback | |
| 215 | + | } | |
| 216 | + | if !k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | |
| 217 | + | return false, nil | |
| 218 | + | } | |
| 219 | + | if _, ok := k.secretHeld[k.repeating]; !ok { | |
| 220 | + | return false, nil | |
| 221 | + | } | |
| 222 | + | key, n, err := k.secretKey(k.repeating, true, scratch) | |
| 223 | + | if err != nil { | |
| 224 | + | k.ClearSecret() | |
| 225 | + | return false, err | |
| 226 | + | } | |
| 227 | + | if !secretRepeatable(key) { | |
| 228 | + | k.repeating = 0 | |
| 229 | + | return false, nil | |
| 230 | + | } | |
| 231 | + | k.next = now.Add(time.Second / time.Duration(k.rate)) | |
| 232 | + | consume(key, scratch[:n:n]) | |
| 233 | + | return true, nil | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | package keyboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "time" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ||||
| 8 | + | xkb "github.com/bnema/purego-xkbcommon" | |||
| 9 | + | "github.com/bnema/purego-xkbcommon/raw" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // SecretKind is the limited editing vocabulary accepted by secret consumers. | |||
| 13 | + | type SecretKind uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | SecretNone SecretKind = iota | |||
| 17 | + | SecretBytes | |||
| 18 | + | SecretBackspace | |||
| 19 | + | SecretReturn | |||
| 20 | + | SecretEscape | |||
| 21 | + | SecretClear // Control+U | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // SecretKey contains scalar metadata only, never names or text. Bytes are | |||
| 25 | + | // supplied separately and exclusively within EventSecret/TickSecret callbacks. | |||
| 26 | + | type SecretKey struct { | |||
| 27 | + | Physical, Logical uint32 | |||
| 28 | + | Kind SecretKind | |||
| 29 | + | Pressed, Repeat bool | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | var errSecretCallback = errors.New("keyboard: secret callback is nil") | |||
| 33 | + | ||||
| 34 | + | // ClearSecret cancels secret-held keys, repeat and local compose. It retains | |||
| 35 | + | // the compositor's current keymap and modifier mask. No text is cached here. | |||
| 36 | + | func (k *Keyboard) ClearSecret() { | |||
| 37 | + | clear(k.secretHeld) | |||
| 38 | + | clear(k.held) // fail-closed even when secret entry fails from ordinary mode | |||
| 39 | + | k.repeating = 0 | |||
| 40 | + | k.next = time.Time{} | |||
| 41 | + | k.composePending = false | |||
| 42 | + | if k.compose != nil { | |||
| 43 | + | _ = k.compose.Reset() | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // EventSecret interprets a physical event without calling any string-producing | |||
| 48 | + | // XKB API or populating Key/held. scratch length must be 1..512, including space | |||
| 49 | + | // for native NUL (max payload 511). The callback is synchronous and must not | |||
| 50 | + | // retain bytes or convert them to strings. scratch is valid only for that call; | |||
| 51 | + | // this method clears it on entry and every exit, in addition to caller wiping. | |||
| 52 | + | // Enter, Escape and Control+U are initial-press controls, never repeats. | |||
| 53 | + | // Release callbacks carry no bytes and Pressed=false; consumers must act on | |||
| 54 | + | // controls only when Pressed=true. Switching APIs cancels the other path. | |||
| 55 | + | func (k *Keyboard) EventSecret(evdev uint32, pressed bool, now time.Time, scratch []byte, consume func(SecretKey, []byte)) error { | |||
| 56 | + | clear(scratch) | |||
| 57 | + | defer clear(scratch) | |||
| 58 | + | if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer { | |||
| 59 | + | k.ClearSecret() | |||
| 60 | + | return xkb.ErrUTF8Buffer | |||
| 61 | + | } | |||
| 62 | + | if consume == nil { | |||
| 63 | + | k.ClearSecret() | |||
| 64 | + | return errSecretCallback | |||
| 65 | + | } | |||
| 66 | + | if !k.secretMode { | |||
| 67 | + | k.Focus(k.focus) // discard ordinary held text and compose on mode entry | |||
| 68 | + | k.secretMode = true | |||
| 69 | + | if k.secretHeld == nil { | |||
| 70 | + | k.secretHeld = make(map[uint32]SecretKey) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | if !pressed { | |||
| 74 | + | key, ok := k.secretHeld[evdev] | |||
| 75 | + | delete(k.secretHeld, evdev) | |||
| 76 | + | if k.repeating == evdev { | |||
| 77 | + | k.repeating = 0 | |||
| 78 | + | } | |||
| 79 | + | if ok && key.Kind != SecretNone { | |||
| 80 | + | key.Pressed = false | |||
| 81 | + | consume(key, nil) | |||
| 82 | + | } | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | if !k.focus || k.state == nil { | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | _, repeat := k.secretHeld[evdev] | |||
| 89 | + | key, n, err := k.secretKey(evdev, repeat, scratch) | |||
| 90 | + | if err != nil { | |||
| 91 | + | k.ClearSecret() | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | k.secretHeld[evdev] = key | |||
| 95 | + | if key.Kind == SecretReturn || key.Kind == SecretEscape || key.Kind == SecretClear { | |||
| 96 | + | // Clear/submit/cancel must stop a previously held printable repeat | |||
| 97 | + | // before the consumer runs; releasing Control must not resurrect it. | |||
| 98 | + | k.repeating = 0 | |||
| 99 | + | k.next = time.Time{} | |||
| 100 | + | } | |||
| 101 | + | if k.rate > 0 && secretRepeatable(key) { | |||
| 102 | + | k.repeating = evdev | |||
| 103 | + | k.next = now.Add(k.delay) | |||
| 104 | + | } | |||
| 105 | + | if key.Kind != SecretNone { | |||
| 106 | + | consume(key, scratch[:n:n]) | |||
| 107 | + | } | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func secretRepeatable(key SecretKey) bool { | |||
| 112 | + | return key.Kind == SecretBackspace || key.Kind == SecretBytes && repeatable(key.Logical) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (k *Keyboard) secretKey(evdev uint32, repeat bool, scratch []byte) (SecretKey, int, error) { | |||
| 116 | + | code := xkb.WaylandKeycode(evdev) | |||
| 117 | + | sym, err := k.state.KeySym(code) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return SecretKey{}, 0, err | |||
| 120 | + | } | |||
| 121 | + | key := SecretKey{Physical: evdev, Logical: sym, Pressed: true, Repeat: repeat} | |||
| 122 | + | _, ctrl, err := k.modifiers() | |||
| 123 | + | if err != nil { | |||
| 124 | + | return SecretKey{}, 0, err | |||
| 125 | + | } | |||
| 126 | + | switch { | |||
| 127 | + | case sym == raw.XKB_KEY_BackSpace: | |||
| 128 | + | key.Kind = SecretBackspace | |||
| 129 | + | case sym == raw.XKB_KEY_Return || sym == raw.XKB_KEY_KP_Enter: | |||
| 130 | + | key.Kind = SecretReturn | |||
| 131 | + | case sym == raw.XKB_KEY_Escape: | |||
| 132 | + | key.Kind = SecretEscape | |||
| 133 | + | case ctrl && (sym == raw.XKB_KEY_u || sym == raw.XKB_KEY_U): | |||
| 134 | + | key.Kind = SecretClear | |||
| 135 | + | } | |||
| 136 | + | if key.Kind != SecretNone { | |||
| 137 | + | if k.compose != nil { | |||
| 138 | + | _ = k.compose.Reset() | |||
| 139 | + | } | |||
| 140 | + | k.composePending = false | |||
| 141 | + | if repeat && key.Kind != SecretBackspace { | |||
| 142 | + | key.Kind = SecretNone | |||
| 143 | + | } | |||
| 144 | + | return key, 0, nil | |||
| 145 | + | } | |||
| 146 | + | // Other Control combinations are not secret bytes or application shortcuts. | |||
| 147 | + | if ctrl { | |||
| 148 | + | return key, 0, nil | |||
| 149 | + | } | |||
| 150 | + | if k.compose != nil && !repeat { | |||
| 151 | + | if _, err := k.compose.Feed(sym); err != nil { | |||
| 152 | + | return key, 0, err | |||
| 153 | + | } | |||
| 154 | + | status, err := k.compose.Status() | |||
| 155 | + | if err != nil { | |||
| 156 | + | return key, 0, err | |||
| 157 | + | } | |||
| 158 | + | switch status { | |||
| 159 | + | case raw.XKB_COMPOSE_COMPOSING: | |||
| 160 | + | k.composePending = true | |||
| 161 | + | return key, 0, nil | |||
| 162 | + | case raw.XKB_COMPOSE_CANCELLED: | |||
| 163 | + | _ = k.compose.Reset() | |||
| 164 | + | k.composePending = false | |||
| 165 | + | return key, 0, nil | |||
| 166 | + | case raw.XKB_COMPOSE_COMPOSED: | |||
| 167 | + | n, err := k.compose.UTF8Into(scratch) | |||
| 168 | + | _ = k.compose.Reset() | |||
| 169 | + | k.composePending = false | |||
| 170 | + | if err != nil { | |||
| 171 | + | return key, 0, err | |||
| 172 | + | } | |||
| 173 | + | return classifySecretBytes(key, scratch, n), n, nil | |||
| 174 | + | default: | |||
| 175 | + | k.composePending = false | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | if k.composePending { | |||
| 179 | + | return key, 0, nil | |||
| 180 | + | } | |||
| 181 | + | n, err := k.state.UTF8Into(code, scratch) | |||
| 182 | + | if err != nil { | |||
| 183 | + | return key, 0, err | |||
| 184 | + | } | |||
| 185 | + | return classifySecretBytes(key, scratch, n), n, nil | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func classifySecretBytes(key SecretKey, scratch []byte, n int) SecretKey { | |||
| 189 | + | if n == 0 || !utf8.Valid(scratch[:n]) { | |||
| 190 | + | return key | |||
| 191 | + | } | |||
| 192 | + | for rest := scratch[:n]; len(rest) > 0; { | |||
| 193 | + | r, size := utf8.DecodeRune(rest) | |||
| 194 | + | if r < 0x20 || r == 0x7f { | |||
| 195 | + | return key | |||
| 196 | + | } | |||
| 197 | + | rest = rest[size:] | |||
| 198 | + | } | |||
| 199 | + | key.Kind = SecretBytes | |||
| 200 | + | return key | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // TickSecret resolves repeat bytes using the current mask/group, not press-time | |||
| 204 | + | // plaintext. Compose is never fed by repeats. It reports whether a callback ran. | |||
| 205 | + | func (k *Keyboard) TickSecret(now time.Time, scratch []byte, consume func(SecretKey, []byte)) (bool, error) { | |||
| 206 | + | clear(scratch) | |||
| 207 | + | defer clear(scratch) | |||
| 208 | + | if len(scratch) < 1 || len(scratch) > xkb.MaxUTF8Buffer { | |||
| 209 | + | k.ClearSecret() | |||
| 210 | + | return false, xkb.ErrUTF8Buffer | |||
| 211 | + | } | |||
| 212 | + | if consume == nil { | |||
| 213 | + | k.ClearSecret() | |||
| 214 | + | return false, errSecretCallback | |||
| 215 | + | } | |||
| 216 | + | if !k.secretMode || !k.focus || k.rate <= 0 || k.repeating == 0 || now.Before(k.next) { | |||
| 217 | + | return false, nil | |||
| 218 | + | } | |||
| 219 | + | if _, ok := k.secretHeld[k.repeating]; !ok { | |||
| 220 | + | return false, nil | |||
| 221 | + | } | |||
| 222 | + | key, n, err := k.secretKey(k.repeating, true, scratch) | |||
| 223 | + | if err != nil { | |||
| 224 | + | k.ClearSecret() | |||
| 225 | + | return false, err | |||
| 226 | + | } | |||
| 227 | + | if !secretRepeatable(key) { | |||
| 228 | + | k.repeating = 0 | |||
| 229 | + | return false, nil | |||
| 230 | + | } | |||
| 231 | + | k.next = now.Add(time.Second / time.Duration(k.rate)) | |||
| 232 | + | consume(key, scratch[:n:n]) | |||
| 233 | + | return true, nil | |||
| 234 | + | } | |||
A internal/keyboard/secret_test.go +414 -0
| @@ -0,0 +1,414 @@ | |||
| 1 | + | package keyboard | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "reflect" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | xkb "github.com/bnema/purego-xkbcommon" | |
| 12 | + | "github.com/bnema/purego-xkbcommon/raw" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestSecretASCIIAndMultibyteWithoutRetainedText(t *testing.T) { | |
| 16 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 17 | + | defer k.Close() | |
| 18 | + | for _, tc := range []struct { | |
| 19 | + | layout string | |
| 20 | + | code uint32 | |
| 21 | + | mod string | |
| 22 | + | want []byte | |
| 23 | + | }{ | |
| 24 | + | {"us", 16, "", []byte{'q'}}, {"de", 18, "Mod5", []byte{0xe2, 0x82, 0xac}}, | |
| 25 | + | } { | |
| 26 | + | if err := k.Rules(tc.layout, ""); err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if tc.mod != "" { | |
| 30 | + | idx, err := k.keymap.ModIndex(tc.mod) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | var scratch [512]byte | |
| 39 | + | calls := 0 | |
| 40 | + | err := k.EventSecret(tc.code, true, time.Now(), scratch[:], func(key SecretKey, data []byte) { | |
| 41 | + | calls++ | |
| 42 | + | if key.Kind != SecretBytes || !key.Pressed || key.Repeat || !bytes.Equal(data, tc.want) { | |
| 43 | + | t.Fatal("wrong scoped bytes/metadata") | |
| 44 | + | } | |
| 45 | + | if cap(data) != len(data) { | |
| 46 | + | t.Fatal("callback can access scratch beyond payload") | |
| 47 | + | } | |
| 48 | + | if len(data) > 0 && &data[0] != &scratch[0] { | |
| 49 | + | t.Fatal("bytes copied instead of caller scratch") | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | if err != nil || calls != 1 { | |
| 53 | + | t.Fatal(calls, err) | |
| 54 | + | } | |
| 55 | + | if !bytes.Equal(scratch[:], make([]byte, len(scratch))) || len(k.held) != 0 { | |
| 56 | + | t.Fatal("plaintext retained") | |
| 57 | + | } | |
| 58 | + | if key := k.secretHeld[tc.code]; key.Kind != SecretBytes || key.Physical != tc.code { | |
| 59 | + | t.Fatal("missing scalar held metadata") | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | for _, typ := range []reflect.Type{reflect.TypeFor[SecretKey]()} { | |
| 63 | + | for i := 0; i < typ.NumField(); i++ { | |
| 64 | + | if typ.Field(i).Type.Kind() == reflect.String || typ.Field(i).Type.Kind() == reflect.Slice { | |
| 65 | + | t.Fatal("secret metadata retains payload") | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestSecretComposeAndClear(t *testing.T) { | |
| 72 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 73 | + | defer k.Close() | |
| 74 | + | if err := k.Rules("de", ""); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | var scratch [512]byte | |
| 78 | + | now := time.Now() | |
| 79 | + | calls := 0 | |
| 80 | + | consume := func(key SecretKey, data []byte) { | |
| 81 | + | if !key.Pressed { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | calls++ | |
| 85 | + | if key.Kind != SecretBytes || !bytes.Equal(data, []byte{0xc3, 0xaa}) { | |
| 86 | + | t.Fatal("compose bytes incorrect") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | for _, ev := range []struct { | |
| 90 | + | code uint32 | |
| 91 | + | down bool | |
| 92 | + | }{{41, true}, {41, false}, {18, true}} { | |
| 93 | + | if err := k.EventSecret(ev.code, ev.down, now, scratch[:], consume); err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | if calls != 1 || k.composePending { | |
| 98 | + | t.Fatal("compose did not finish") | |
| 99 | + | } | |
| 100 | + | k.ClearSecret() | |
| 101 | + | if len(k.secretHeld) != 0 || k.repeating != 0 { | |
| 102 | + | t.Fatal("clear retained held/repeat") | |
| 103 | + | } | |
| 104 | + | if err := k.EventSecret(41, true, now, scratch[:], consume); err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | k.Focus(false) | |
| 108 | + | k.Focus(true) | |
| 109 | + | if err := k.EventSecret(18, true, now, scratch[:], func(key SecretKey, data []byte) { | |
| 110 | + | if !bytes.Equal(data, []byte{'e'}) { | |
| 111 | + | t.Fatal("focus loss preserved compose") | |
| 112 | + | } | |
| 113 | + | }); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestSecretControlsAndRepeat(t *testing.T) { | |
| 119 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 120 | + | defer k.Close() | |
| 121 | + | if err := k.Rules("us", ""); err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | k.RepeatInfo(10, 100*time.Millisecond) | |
| 125 | + | now := time.Now() | |
| 126 | + | var scratch [512]byte | |
| 127 | + | for _, tc := range []struct { | |
| 128 | + | code uint32 | |
| 129 | + | kind SecretKind | |
| 130 | + | repeat bool | |
| 131 | + | }{{14, SecretBackspace, true}, {28, SecretReturn, false}, {1, SecretEscape, false}} { | |
| 132 | + | k.ClearSecret() | |
| 133 | + | calls := 0 | |
| 134 | + | consume := func(key SecretKey, data []byte) { | |
| 135 | + | if len(data) != 0 || key.Kind != tc.kind { | |
| 136 | + | t.Fatal("control leaked text") | |
| 137 | + | } | |
| 138 | + | calls++ | |
| 139 | + | } | |
| 140 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil { | |
| 141 | + | t.Fatal(err) | |
| 142 | + | } | |
| 143 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], consume) | |
| 147 | + | if err != nil || got != tc.repeat { | |
| 148 | + | t.Fatal(got, err) | |
| 149 | + | } | |
| 150 | + | want := 1 | |
| 151 | + | if tc.repeat { | |
| 152 | + | want = 3 | |
| 153 | + | } | |
| 154 | + | if calls != want { | |
| 155 | + | t.Fatalf("control callbacks=%d want=%d", calls, want) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | k.ClearSecret() | |
| 159 | + | idx, err := k.keymap.ModIndex("Control") | |
| 160 | + | if err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | if err := k.EventSecret(22, true, now, scratch[:], func(key SecretKey, data []byte) { | |
| 167 | + | if key.Kind != SecretClear || len(data) != 0 { | |
| 168 | + | t.Fatal("CtrlU not named control") | |
| 169 | + | } | |
| 170 | + | }); err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | k.ClearSecret() | |
| 174 | + | _ = k.Mask(0, 0, 0, 0, 0, 0) | |
| 175 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |
| 176 | + | t.Fatal(err) | |
| 177 | + | } | |
| 178 | + | shift, _ := k.keymap.ModIndex("Shift") | |
| 179 | + | _ = k.Mask(1<<shift, 0, 0, 0, 0, 0) | |
| 180 | + | got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], func(key SecretKey, data []byte) { | |
| 181 | + | if !key.Repeat || key.Logical != raw.XKB_KEY_Q || !bytes.Equal(data, []byte{'Q'}) { | |
| 182 | + | t.Fatal("repeat cached press-time bytes") | |
| 183 | + | } | |
| 184 | + | }) | |
| 185 | + | if err != nil || !got { | |
| 186 | + | t.Fatal(got, err) | |
| 187 | + | } | |
| 188 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 189 | + | t.Fatal("ordinary repeat exposed secret") | |
| 190 | + | } | |
| 191 | + | k.Focus(false) | |
| 192 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("repeat after focus loss") }); got || err != nil { | |
| 193 | + | t.Fatal(got, err) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestSecretBoundsAndOrdinaryIsolation(t *testing.T) { | |
| 198 | + | k := requiredKeyboard(t, "") | |
| 199 | + | defer k.Close() | |
| 200 | + | if err := k.Rules("us", ""); err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | now := time.Now() | |
| 204 | + | if _, err := k.Event(16, true, now); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | for _, size := range []int{0, 513} { | |
| 208 | + | buf := make([]byte, size) | |
| 209 | + | if err := k.EventSecret(30, true, now, buf, func(SecretKey, []byte) { t.Fatal("invalid bound callback") }); !errors.Is(err, xkb.ErrUTF8Buffer) { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | short := []byte{0xff} | |
| 214 | + | if err := k.EventSecret(30, true, now, short, func(SecretKey, []byte) { t.Fatal("truncated callback") }); !errors.Is(err, io.ErrShortBuffer) { | |
| 215 | + | t.Fatal(err) | |
| 216 | + | } | |
| 217 | + | if short[0] != 0 || len(k.held) != 0 || len(k.secretHeld) != 0 { | |
| 218 | + | t.Fatal("error retained bytes/state") | |
| 219 | + | } | |
| 220 | + | var scratch [512]byte | |
| 221 | + | if err := k.EventSecret(30, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |
| 222 | + | t.Fatal(err) | |
| 223 | + | } | |
| 224 | + | if key, err := k.Event(30, false, now); err != nil || key.Text != "" || key.Name != "" { | |
| 225 | + | t.Fatal("ordinary release exposed secret") | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func TestSecretRepeatBoundedAllocations(t *testing.T) { | |
| 230 | + | k := requiredKeyboard(t, "") | |
| 231 | + | defer k.Close() | |
| 232 | + | if err := k.Rules("us", ""); err != nil { | |
| 233 | + | t.Fatal(err) | |
| 234 | + | } | |
| 235 | + | k.RepeatInfo(20, 0) | |
| 236 | + | var scratch [512]byte | |
| 237 | + | now := time.Now() | |
| 238 | + | consume := func(SecretKey, []byte) {} | |
| 239 | + | if err := k.EventSecret(16, true, now, scratch[:], consume); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | allocs := testing.AllocsPerRun(100, func() { | |
| 243 | + | now = now.Add(time.Second) | |
| 244 | + | if _, err := k.TickSecret(now, scratch[:], consume); err != nil { | |
| 245 | + | t.Fatal(err) | |
| 246 | + | } | |
| 247 | + | }) | |
| 248 | + | // Fixed native-dispatch/modifier-query overhead is allowed; no allocation | |
| 249 | + | // should scale with payload length or retain a text-bearing Key. | |
| 250 | + | t.Logf("secret repeat fixed allocations: %v", allocs) | |
| 251 | + | if allocs > 24 { | |
| 252 | + | t.Fatalf("secret repeat allocations=%v", allocs) | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestSecretControlsCancelPriorPrintableRepeat(t *testing.T) { | |
| 257 | + | for _, tc := range []struct { | |
| 258 | + | code uint32 | |
| 259 | + | kind SecretKind | |
| 260 | + | ctrl bool | |
| 261 | + | }{{28, SecretReturn, false}, {1, SecretEscape, false}, {22, SecretClear, true}} { | |
| 262 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 263 | + | if err := k.Rules("us", ""); err != nil { | |
| 264 | + | t.Fatal(err) | |
| 265 | + | } | |
| 266 | + | k.RepeatInfo(20, 0) | |
| 267 | + | now := time.Now() | |
| 268 | + | var scratch [512]byte | |
| 269 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |
| 270 | + | t.Fatal(err) | |
| 271 | + | } | |
| 272 | + | if tc.ctrl { | |
| 273 | + | idx, err := k.keymap.ModIndex("Control") | |
| 274 | + | if err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], func(key SecretKey, data []byte) { | |
| 282 | + | if key.Kind != tc.kind || len(data) != 0 || k.repeating != 0 || !k.next.IsZero() { | |
| 283 | + | t.Fatal("control callback preceded repeat cancellation") | |
| 284 | + | } | |
| 285 | + | }); err != nil { | |
| 286 | + | t.Fatal(err) | |
| 287 | + | } | |
| 288 | + | if err := k.Mask(0, 0, 0, 0, 0, 0); err != nil { | |
| 289 | + | t.Fatal(err) | |
| 290 | + | } | |
| 291 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("bytes after control") }); got || err != nil { | |
| 292 | + | t.Fatal(got, err) | |
| 293 | + | } | |
| 294 | + | k.Close() | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestSecretValidationErrorsClearAllInputState(t *testing.T) { | |
| 299 | + | for _, ordinary := range []bool{false, true} { | |
| 300 | + | for _, tick := range []bool{false, true} { | |
| 301 | + | for _, invalid := range []string{"empty", "oversize", "nil-callback"} { | |
| 302 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |
| 303 | + | if err := k.Rules("de", ""); err != nil { | |
| 304 | + | t.Fatal(err) | |
| 305 | + | } | |
| 306 | + | k.RepeatInfo(20, 0) | |
| 307 | + | now := time.Now() | |
| 308 | + | var scratch [512]byte | |
| 309 | + | if ordinary { | |
| 310 | + | if _, err := k.Event(16, true, now); err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | } else { | |
| 314 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | // Real native composing state must also be reset on validation failure. | |
| 319 | + | if _, err := k.compose.Feed(raw.XKB_KEY_dead_circumflex); err != nil { | |
| 320 | + | t.Fatal(err) | |
| 321 | + | } | |
| 322 | + | k.composePending = true | |
| 323 | + | buf := scratch[:] | |
| 324 | + | consume := func(SecretKey, []byte) { t.Fatal("invalid call delivered") } | |
| 325 | + | want := xkb.ErrUTF8Buffer | |
| 326 | + | switch invalid { | |
| 327 | + | case "empty": | |
| 328 | + | buf = nil | |
| 329 | + | case "oversize": | |
| 330 | + | buf = make([]byte, 513) | |
| 331 | + | case "nil-callback": | |
| 332 | + | consume = nil | |
| 333 | + | want = errSecretCallback | |
| 334 | + | } | |
| 335 | + | var err error | |
| 336 | + | if tick { | |
| 337 | + | _, err = k.TickSecret(now.Add(time.Second), buf, consume) | |
| 338 | + | } else { | |
| 339 | + | err = k.EventSecret(16, false, now, buf, consume) | |
| 340 | + | } // release validation must not retain held state | |
| 341 | + | if !errors.Is(err, want) { | |
| 342 | + | t.Fatal(invalid, err) | |
| 343 | + | } | |
| 344 | + | status, err := k.compose.Status() | |
| 345 | + | if err != nil || status != raw.XKB_COMPOSE_NOTHING { | |
| 346 | + | t.Fatal("compose survived validation error") | |
| 347 | + | } | |
| 348 | + | if len(k.held) != 0 || len(k.secretHeld) != 0 || k.repeating != 0 || !k.next.IsZero() || k.composePending { | |
| 349 | + | t.Fatal("validation error retained ordinary/secret state") | |
| 350 | + | } | |
| 351 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |
| 352 | + | t.Fatal("ordinary repeat after failed secret entry") | |
| 353 | + | } | |
| 354 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("secret repeat after failed validation") }); got || err != nil { | |
| 355 | + | t.Fatal(got, err) | |
| 356 | + | } | |
| 357 | + | k.Close() | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestSecretCallbackPanicWipesScratch(t *testing.T) { | |
| 364 | + | k := requiredKeyboard(t, "") | |
| 365 | + | defer k.Close() | |
| 366 | + | if err := k.Rules("us", ""); err != nil { | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | var scratch [512]byte | |
| 370 | + | func() { | |
| 371 | + | defer func() { | |
| 372 | + | if recover() == nil { | |
| 373 | + | t.Fatal("callback did not panic") | |
| 374 | + | } | |
| 375 | + | }() | |
| 376 | + | _ = k.EventSecret(16, true, time.Now(), scratch[:], func(_ SecretKey, data []byte) { | |
| 377 | + | if !bytes.Equal(data, []byte{'q'}) { | |
| 378 | + | t.Fatal("no bytes before panic") | |
| 379 | + | } | |
| 380 | + | panic("consumer failure") | |
| 381 | + | }) | |
| 382 | + | }() | |
| 383 | + | if !bytes.Equal(scratch[:], make([]byte, len(scratch))) { | |
| 384 | + | t.Fatal("panic retained scratch payload") | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestSecretRepeatTracksLayoutGroup(t *testing.T) { | |
| 389 | + | k := requiredKeyboard(t, "") | |
| 390 | + | defer k.Close() | |
| 391 | + | if err := k.Rules("us,fr", ""); err != nil { | |
| 392 | + | t.Fatal(err) | |
| 393 | + | } | |
| 394 | + | k.RepeatInfo(20, 0) | |
| 395 | + | now := time.Now() | |
| 396 | + | var scratch [512]byte | |
| 397 | + | if err := k.EventSecret(16, true, now, scratch[:], func(_ SecretKey, data []byte) { | |
| 398 | + | if !bytes.Equal(data, []byte{'q'}) { | |
| 399 | + | t.Fatal("initial group") | |
| 400 | + | } | |
| 401 | + | }); err != nil { | |
| 402 | + | t.Fatal(err) | |
| 403 | + | } | |
| 404 | + | if err := k.Mask(0, 0, 0, 0, 0, 1); err != nil { | |
| 405 | + | t.Fatal(err) | |
| 406 | + | } | |
| 407 | + | if ok, err := k.TickSecret(now.Add(time.Second), scratch[:], func(key SecretKey, data []byte) { | |
| 408 | + | if !key.Repeat || !bytes.Equal(data, []byte{'a'}) { | |
| 409 | + | t.Fatal("repeat cached previous group") | |
| 410 | + | } | |
| 411 | + | }); err != nil || !ok { | |
| 412 | + | t.Fatal(ok, err) | |
| 413 | + | } | |
| 414 | + | } | |
| @@ -0,0 +1,414 @@ | |||||
| 1 | + | package keyboard | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "reflect" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | xkb "github.com/bnema/purego-xkbcommon" | |||
| 12 | + | "github.com/bnema/purego-xkbcommon/raw" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSecretASCIIAndMultibyteWithoutRetainedText(t *testing.T) { | |||
| 16 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |||
| 17 | + | defer k.Close() | |||
| 18 | + | for _, tc := range []struct { | |||
| 19 | + | layout string | |||
| 20 | + | code uint32 | |||
| 21 | + | mod string | |||
| 22 | + | want []byte | |||
| 23 | + | }{ | |||
| 24 | + | {"us", 16, "", []byte{'q'}}, {"de", 18, "Mod5", []byte{0xe2, 0x82, 0xac}}, | |||
| 25 | + | } { | |||
| 26 | + | if err := k.Rules(tc.layout, ""); err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | if tc.mod != "" { | |||
| 30 | + | idx, err := k.keymap.ModIndex(tc.mod) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | var scratch [512]byte | |||
| 39 | + | calls := 0 | |||
| 40 | + | err := k.EventSecret(tc.code, true, time.Now(), scratch[:], func(key SecretKey, data []byte) { | |||
| 41 | + | calls++ | |||
| 42 | + | if key.Kind != SecretBytes || !key.Pressed || key.Repeat || !bytes.Equal(data, tc.want) { | |||
| 43 | + | t.Fatal("wrong scoped bytes/metadata") | |||
| 44 | + | } | |||
| 45 | + | if cap(data) != len(data) { | |||
| 46 | + | t.Fatal("callback can access scratch beyond payload") | |||
| 47 | + | } | |||
| 48 | + | if len(data) > 0 && &data[0] != &scratch[0] { | |||
| 49 | + | t.Fatal("bytes copied instead of caller scratch") | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | if err != nil || calls != 1 { | |||
| 53 | + | t.Fatal(calls, err) | |||
| 54 | + | } | |||
| 55 | + | if !bytes.Equal(scratch[:], make([]byte, len(scratch))) || len(k.held) != 0 { | |||
| 56 | + | t.Fatal("plaintext retained") | |||
| 57 | + | } | |||
| 58 | + | if key := k.secretHeld[tc.code]; key.Kind != SecretBytes || key.Physical != tc.code { | |||
| 59 | + | t.Fatal("missing scalar held metadata") | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | for _, typ := range []reflect.Type{reflect.TypeFor[SecretKey]()} { | |||
| 63 | + | for i := 0; i < typ.NumField(); i++ { | |||
| 64 | + | if typ.Field(i).Type.Kind() == reflect.String || typ.Field(i).Type.Kind() == reflect.Slice { | |||
| 65 | + | t.Fatal("secret metadata retains payload") | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestSecretComposeAndClear(t *testing.T) { | |||
| 72 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |||
| 73 | + | defer k.Close() | |||
| 74 | + | if err := k.Rules("de", ""); err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | var scratch [512]byte | |||
| 78 | + | now := time.Now() | |||
| 79 | + | calls := 0 | |||
| 80 | + | consume := func(key SecretKey, data []byte) { | |||
| 81 | + | if !key.Pressed { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | calls++ | |||
| 85 | + | if key.Kind != SecretBytes || !bytes.Equal(data, []byte{0xc3, 0xaa}) { | |||
| 86 | + | t.Fatal("compose bytes incorrect") | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | for _, ev := range []struct { | |||
| 90 | + | code uint32 | |||
| 91 | + | down bool | |||
| 92 | + | }{{41, true}, {41, false}, {18, true}} { | |||
| 93 | + | if err := k.EventSecret(ev.code, ev.down, now, scratch[:], consume); err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | if calls != 1 || k.composePending { | |||
| 98 | + | t.Fatal("compose did not finish") | |||
| 99 | + | } | |||
| 100 | + | k.ClearSecret() | |||
| 101 | + | if len(k.secretHeld) != 0 || k.repeating != 0 { | |||
| 102 | + | t.Fatal("clear retained held/repeat") | |||
| 103 | + | } | |||
| 104 | + | if err := k.EventSecret(41, true, now, scratch[:], consume); err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | k.Focus(false) | |||
| 108 | + | k.Focus(true) | |||
| 109 | + | if err := k.EventSecret(18, true, now, scratch[:], func(key SecretKey, data []byte) { | |||
| 110 | + | if !bytes.Equal(data, []byte{'e'}) { | |||
| 111 | + | t.Fatal("focus loss preserved compose") | |||
| 112 | + | } | |||
| 113 | + | }); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestSecretControlsAndRepeat(t *testing.T) { | |||
| 119 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |||
| 120 | + | defer k.Close() | |||
| 121 | + | if err := k.Rules("us", ""); err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | k.RepeatInfo(10, 100*time.Millisecond) | |||
| 125 | + | now := time.Now() | |||
| 126 | + | var scratch [512]byte | |||
| 127 | + | for _, tc := range []struct { | |||
| 128 | + | code uint32 | |||
| 129 | + | kind SecretKind | |||
| 130 | + | repeat bool | |||
| 131 | + | }{{14, SecretBackspace, true}, {28, SecretReturn, false}, {1, SecretEscape, false}} { | |||
| 132 | + | k.ClearSecret() | |||
| 133 | + | calls := 0 | |||
| 134 | + | consume := func(key SecretKey, data []byte) { | |||
| 135 | + | if len(data) != 0 || key.Kind != tc.kind { | |||
| 136 | + | t.Fatal("control leaked text") | |||
| 137 | + | } | |||
| 138 | + | calls++ | |||
| 139 | + | } | |||
| 140 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil { | |||
| 141 | + | t.Fatal(err) | |||
| 142 | + | } | |||
| 143 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], consume); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], consume) | |||
| 147 | + | if err != nil || got != tc.repeat { | |||
| 148 | + | t.Fatal(got, err) | |||
| 149 | + | } | |||
| 150 | + | want := 1 | |||
| 151 | + | if tc.repeat { | |||
| 152 | + | want = 3 | |||
| 153 | + | } | |||
| 154 | + | if calls != want { | |||
| 155 | + | t.Fatalf("control callbacks=%d want=%d", calls, want) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | k.ClearSecret() | |||
| 159 | + | idx, err := k.keymap.ModIndex("Control") | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | if err := k.EventSecret(22, true, now, scratch[:], func(key SecretKey, data []byte) { | |||
| 167 | + | if key.Kind != SecretClear || len(data) != 0 { | |||
| 168 | + | t.Fatal("CtrlU not named control") | |||
| 169 | + | } | |||
| 170 | + | }); err != nil { | |||
| 171 | + | t.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | k.ClearSecret() | |||
| 174 | + | _ = k.Mask(0, 0, 0, 0, 0, 0) | |||
| 175 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |||
| 176 | + | t.Fatal(err) | |||
| 177 | + | } | |||
| 178 | + | shift, _ := k.keymap.ModIndex("Shift") | |||
| 179 | + | _ = k.Mask(1<<shift, 0, 0, 0, 0, 0) | |||
| 180 | + | got, err := k.TickSecret(now.Add(100*time.Millisecond), scratch[:], func(key SecretKey, data []byte) { | |||
| 181 | + | if !key.Repeat || key.Logical != raw.XKB_KEY_Q || !bytes.Equal(data, []byte{'Q'}) { | |||
| 182 | + | t.Fatal("repeat cached press-time bytes") | |||
| 183 | + | } | |||
| 184 | + | }) | |||
| 185 | + | if err != nil || !got { | |||
| 186 | + | t.Fatal(got, err) | |||
| 187 | + | } | |||
| 188 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |||
| 189 | + | t.Fatal("ordinary repeat exposed secret") | |||
| 190 | + | } | |||
| 191 | + | k.Focus(false) | |||
| 192 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("repeat after focus loss") }); got || err != nil { | |||
| 193 | + | t.Fatal(got, err) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestSecretBoundsAndOrdinaryIsolation(t *testing.T) { | |||
| 198 | + | k := requiredKeyboard(t, "") | |||
| 199 | + | defer k.Close() | |||
| 200 | + | if err := k.Rules("us", ""); err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | now := time.Now() | |||
| 204 | + | if _, err := k.Event(16, true, now); err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | for _, size := range []int{0, 513} { | |||
| 208 | + | buf := make([]byte, size) | |||
| 209 | + | if err := k.EventSecret(30, true, now, buf, func(SecretKey, []byte) { t.Fatal("invalid bound callback") }); !errors.Is(err, xkb.ErrUTF8Buffer) { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | short := []byte{0xff} | |||
| 214 | + | if err := k.EventSecret(30, true, now, short, func(SecretKey, []byte) { t.Fatal("truncated callback") }); !errors.Is(err, io.ErrShortBuffer) { | |||
| 215 | + | t.Fatal(err) | |||
| 216 | + | } | |||
| 217 | + | if short[0] != 0 || len(k.held) != 0 || len(k.secretHeld) != 0 { | |||
| 218 | + | t.Fatal("error retained bytes/state") | |||
| 219 | + | } | |||
| 220 | + | var scratch [512]byte | |||
| 221 | + | if err := k.EventSecret(30, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |||
| 222 | + | t.Fatal(err) | |||
| 223 | + | } | |||
| 224 | + | if key, err := k.Event(30, false, now); err != nil || key.Text != "" || key.Name != "" { | |||
| 225 | + | t.Fatal("ordinary release exposed secret") | |||
| 226 | + | } | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func TestSecretRepeatBoundedAllocations(t *testing.T) { | |||
| 230 | + | k := requiredKeyboard(t, "") | |||
| 231 | + | defer k.Close() | |||
| 232 | + | if err := k.Rules("us", ""); err != nil { | |||
| 233 | + | t.Fatal(err) | |||
| 234 | + | } | |||
| 235 | + | k.RepeatInfo(20, 0) | |||
| 236 | + | var scratch [512]byte | |||
| 237 | + | now := time.Now() | |||
| 238 | + | consume := func(SecretKey, []byte) {} | |||
| 239 | + | if err := k.EventSecret(16, true, now, scratch[:], consume); err != nil { | |||
| 240 | + | t.Fatal(err) | |||
| 241 | + | } | |||
| 242 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 243 | + | now = now.Add(time.Second) | |||
| 244 | + | if _, err := k.TickSecret(now, scratch[:], consume); err != nil { | |||
| 245 | + | t.Fatal(err) | |||
| 246 | + | } | |||
| 247 | + | }) | |||
| 248 | + | // Fixed native-dispatch/modifier-query overhead is allowed; no allocation | |||
| 249 | + | // should scale with payload length or retain a text-bearing Key. | |||
| 250 | + | t.Logf("secret repeat fixed allocations: %v", allocs) | |||
| 251 | + | if allocs > 24 { | |||
| 252 | + | t.Fatalf("secret repeat allocations=%v", allocs) | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func TestSecretControlsCancelPriorPrintableRepeat(t *testing.T) { | |||
| 257 | + | for _, tc := range []struct { | |||
| 258 | + | code uint32 | |||
| 259 | + | kind SecretKind | |||
| 260 | + | ctrl bool | |||
| 261 | + | }{{28, SecretReturn, false}, {1, SecretEscape, false}, {22, SecretClear, true}} { | |||
| 262 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |||
| 263 | + | if err := k.Rules("us", ""); err != nil { | |||
| 264 | + | t.Fatal(err) | |||
| 265 | + | } | |||
| 266 | + | k.RepeatInfo(20, 0) | |||
| 267 | + | now := time.Now() | |||
| 268 | + | var scratch [512]byte | |||
| 269 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |||
| 270 | + | t.Fatal(err) | |||
| 271 | + | } | |||
| 272 | + | if tc.ctrl { | |||
| 273 | + | idx, err := k.keymap.ModIndex("Control") | |||
| 274 | + | if err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | if err := k.Mask(1<<idx, 0, 0, 0, 0, 0); err != nil { | |||
| 278 | + | t.Fatal(err) | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | if err := k.EventSecret(tc.code, true, now, scratch[:], func(key SecretKey, data []byte) { | |||
| 282 | + | if key.Kind != tc.kind || len(data) != 0 || k.repeating != 0 || !k.next.IsZero() { | |||
| 283 | + | t.Fatal("control callback preceded repeat cancellation") | |||
| 284 | + | } | |||
| 285 | + | }); err != nil { | |||
| 286 | + | t.Fatal(err) | |||
| 287 | + | } | |||
| 288 | + | if err := k.Mask(0, 0, 0, 0, 0, 0); err != nil { | |||
| 289 | + | t.Fatal(err) | |||
| 290 | + | } | |||
| 291 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("bytes after control") }); got || err != nil { | |||
| 292 | + | t.Fatal(got, err) | |||
| 293 | + | } | |||
| 294 | + | k.Close() | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestSecretValidationErrorsClearAllInputState(t *testing.T) { | |||
| 299 | + | for _, ordinary := range []bool{false, true} { | |||
| 300 | + | for _, tick := range []bool{false, true} { | |||
| 301 | + | for _, invalid := range []string{"empty", "oversize", "nil-callback"} { | |||
| 302 | + | k := requiredKeyboard(t, "en_US.UTF-8") | |||
| 303 | + | if err := k.Rules("de", ""); err != nil { | |||
| 304 | + | t.Fatal(err) | |||
| 305 | + | } | |||
| 306 | + | k.RepeatInfo(20, 0) | |||
| 307 | + | now := time.Now() | |||
| 308 | + | var scratch [512]byte | |||
| 309 | + | if ordinary { | |||
| 310 | + | if _, err := k.Event(16, true, now); err != nil { | |||
| 311 | + | t.Fatal(err) | |||
| 312 | + | } | |||
| 313 | + | } else { | |||
| 314 | + | if err := k.EventSecret(16, true, now, scratch[:], func(SecretKey, []byte) {}); err != nil { | |||
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | // Real native composing state must also be reset on validation failure. | |||
| 319 | + | if _, err := k.compose.Feed(raw.XKB_KEY_dead_circumflex); err != nil { | |||
| 320 | + | t.Fatal(err) | |||
| 321 | + | } | |||
| 322 | + | k.composePending = true | |||
| 323 | + | buf := scratch[:] | |||
| 324 | + | consume := func(SecretKey, []byte) { t.Fatal("invalid call delivered") } | |||
| 325 | + | want := xkb.ErrUTF8Buffer | |||
| 326 | + | switch invalid { | |||
| 327 | + | case "empty": | |||
| 328 | + | buf = nil | |||
| 329 | + | case "oversize": | |||
| 330 | + | buf = make([]byte, 513) | |||
| 331 | + | case "nil-callback": | |||
| 332 | + | consume = nil | |||
| 333 | + | want = errSecretCallback | |||
| 334 | + | } | |||
| 335 | + | var err error | |||
| 336 | + | if tick { | |||
| 337 | + | _, err = k.TickSecret(now.Add(time.Second), buf, consume) | |||
| 338 | + | } else { | |||
| 339 | + | err = k.EventSecret(16, false, now, buf, consume) | |||
| 340 | + | } // release validation must not retain held state | |||
| 341 | + | if !errors.Is(err, want) { | |||
| 342 | + | t.Fatal(invalid, err) | |||
| 343 | + | } | |||
| 344 | + | status, err := k.compose.Status() | |||
| 345 | + | if err != nil || status != raw.XKB_COMPOSE_NOTHING { | |||
| 346 | + | t.Fatal("compose survived validation error") | |||
| 347 | + | } | |||
| 348 | + | if len(k.held) != 0 || len(k.secretHeld) != 0 || k.repeating != 0 || !k.next.IsZero() || k.composePending { | |||
| 349 | + | t.Fatal("validation error retained ordinary/secret state") | |||
| 350 | + | } | |||
| 351 | + | if _, ok := k.Tick(now.Add(time.Second)); ok { | |||
| 352 | + | t.Fatal("ordinary repeat after failed secret entry") | |||
| 353 | + | } | |||
| 354 | + | if got, err := k.TickSecret(now.Add(time.Second), scratch[:], func(SecretKey, []byte) { t.Fatal("secret repeat after failed validation") }); got || err != nil { | |||
| 355 | + | t.Fatal(got, err) | |||
| 356 | + | } | |||
| 357 | + | k.Close() | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestSecretCallbackPanicWipesScratch(t *testing.T) { | |||
| 364 | + | k := requiredKeyboard(t, "") | |||
| 365 | + | defer k.Close() | |||
| 366 | + | if err := k.Rules("us", ""); err != nil { | |||
| 367 | + | t.Fatal(err) | |||
| 368 | + | } | |||
| 369 | + | var scratch [512]byte | |||
| 370 | + | func() { | |||
| 371 | + | defer func() { | |||
| 372 | + | if recover() == nil { | |||
| 373 | + | t.Fatal("callback did not panic") | |||
| 374 | + | } | |||
| 375 | + | }() | |||
| 376 | + | _ = k.EventSecret(16, true, time.Now(), scratch[:], func(_ SecretKey, data []byte) { | |||
| 377 | + | if !bytes.Equal(data, []byte{'q'}) { | |||
| 378 | + | t.Fatal("no bytes before panic") | |||
| 379 | + | } | |||
| 380 | + | panic("consumer failure") | |||
| 381 | + | }) | |||
| 382 | + | }() | |||
| 383 | + | if !bytes.Equal(scratch[:], make([]byte, len(scratch))) { | |||
| 384 | + | t.Fatal("panic retained scratch payload") | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestSecretRepeatTracksLayoutGroup(t *testing.T) { | |||
| 389 | + | k := requiredKeyboard(t, "") | |||
| 390 | + | defer k.Close() | |||
| 391 | + | if err := k.Rules("us,fr", ""); err != nil { | |||
| 392 | + | t.Fatal(err) | |||
| 393 | + | } | |||
| 394 | + | k.RepeatInfo(20, 0) | |||
| 395 | + | now := time.Now() | |||
| 396 | + | var scratch [512]byte | |||
| 397 | + | if err := k.EventSecret(16, true, now, scratch[:], func(_ SecretKey, data []byte) { | |||
| 398 | + | if !bytes.Equal(data, []byte{'q'}) { | |||
| 399 | + | t.Fatal("initial group") | |||
| 400 | + | } | |||
| 401 | + | }); err != nil { | |||
| 402 | + | t.Fatal(err) | |||
| 403 | + | } | |||
| 404 | + | if err := k.Mask(0, 0, 0, 0, 0, 1); err != nil { | |||
| 405 | + | t.Fatal(err) | |||
| 406 | + | } | |||
| 407 | + | if ok, err := k.TickSecret(now.Add(time.Second), scratch[:], func(key SecretKey, data []byte) { | |||
| 408 | + | if !key.Repeat || !bytes.Equal(data, []byte{'a'}) { | |||
| 409 | + | t.Fatal("repeat cached previous group") | |||
| 410 | + | } | |||
| 411 | + | }); err != nil || !ok { | |||
| 412 | + | t.Fatal(ok, err) | |||
| 413 | + | } | |||
| 414 | + | } | |||
A internal/platform/wayland/connection.go +484 -0
| @@ -0,0 +1,484 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "net" | |
| 10 | + | "os" | |
| 11 | + | "sort" | |
| 12 | + | "sync" | |
| 13 | + | "sync/atomic" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/wlturbo" | |
| 17 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 18 | + | "github.com/bnema/wlturbo/protocol/extsessionlock" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // Connection owns one socket, one reader and one seat. All NewWindow, Apply, | |
| 22 | + | // Outputs, AcquireLock and Close calls belong to the same UI owner goroutine. | |
| 23 | + | // Reader callbacks post immutable events; they never mutate owner window state. | |
| 24 | + | type Connection struct { | |
| 25 | + | Display *wlturbo.Display | |
| 26 | + | events chan Event | |
| 27 | + | stop chan struct{} | |
| 28 | + | done chan struct{} | |
| 29 | + | once sync.Once | |
| 30 | + | started atomic.Bool | |
| 31 | + | windows map[uint32]*Window | |
| 32 | + | routes sync.Map // immutable surface->window identities published by owner | |
| 33 | + | outputs map[uint32]Output | |
| 34 | + | seat *Window // transport-only seat host, never a rendering window | |
| 35 | + | lockManager *extsessionlock.ExtSessionLockManager | |
| 36 | + | lock *extsessionlock.ExtSessionLock | |
| 37 | + | lockFinish bool // finished event applied | |
| 38 | + | lockDone bool // locked event applied | |
| 39 | + | held []Event // lock/output events kept by drainStartup(true); owner only | |
| 40 | + | closed bool | |
| 41 | + | ||
| 42 | + | // Reader pause gate. The owner pauses the reader to register handlers on | |
| 43 | + | // freshly created objects (whose first events may arrive immediately) or to | |
| 44 | + | // run its own roundtrip. While paused, post parks events instead of | |
| 45 | + | // blocking, so neither side can wait on the bounded queue. | |
| 46 | + | socket net.Conn | |
| 47 | + | gate sync.Mutex | |
| 48 | + | pausing bool | |
| 49 | + | pauseSig chan struct{} // closed when a pause is requested | |
| 50 | + | parked []Event | |
| 51 | + | pauseDepth int // owner goroutine only | |
| 52 | + | pauseAck chan struct{} | |
| 53 | + | resume chan struct{} | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Output identifies the registry lifetime, not merely a reusable monitor name. | |
| 57 | + | type Output struct { | |
| 58 | + | Global uint32 | |
| 59 | + | Name string | |
| 60 | + | Scale int32 | |
| 61 | + | proxy *core.Output | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func ConnectConnection(ctx context.Context, name string) (res *Connection, err error) { | |
| 65 | + | if ctx == nil { | |
| 66 | + | return nil, errors.New("nil context") | |
| 67 | + | } | |
| 68 | + | if err := ctx.Err(); err != nil { | |
| 69 | + | return nil, err | |
| 70 | + | } | |
| 71 | + | path, err := waylandSocketPath(name) | |
| 72 | + | if err != nil { | |
| 73 | + | return nil, err | |
| 74 | + | } | |
| 75 | + | socket, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path) | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, fmt.Errorf("failed to connect to Wayland: %w", err) | |
| 78 | + | } | |
| 79 | + | display, err := wlturbo.ConnectFromConn(socket) | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | c := &Connection{Display: display, socket: socket, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window), outputs: make(map[uint32]Output), pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)} | |
| 84 | + | watch := context.AfterFunc(ctx, func() { _ = display.Close() }) | |
| 85 | + | defer func() { | |
| 86 | + | if !watch() && err == nil { | |
| 87 | + | err = ctx.Err() | |
| 88 | + | } | |
| 89 | + | if err != nil { | |
| 90 | + | _ = c.Close() | |
| 91 | + | res = nil | |
| 92 | + | if ctx.Err() != nil { | |
| 93 | + | err = errors.Join(ctx.Err(), err) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | }() | |
| 97 | + | if err = display.Roundtrip(); err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | c.seat = &Window{Display: display, connection: c, events: c.events, readerStop: c.stop} | |
| 101 | + | if err = c.seat.BindSeat(); err != nil { | |
| 102 | + | return nil, err | |
| 103 | + | } | |
| 104 | + | display.Registry().AddGlobalHandler(c) | |
| 105 | + | display.Registry().AddGlobalRemoveHandler(c) | |
| 106 | + | for name, g := range display.Registry().GetGlobals() { | |
| 107 | + | if g.Interface == core.OutputInterface { | |
| 108 | + | if err = c.bindOutput(name, g.Version); err != nil { | |
| 109 | + | return nil, err | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if err = display.Roundtrip(); err != nil { | |
| 114 | + | return nil, err | |
| 115 | + | } | |
| 116 | + | if err = c.drainStartup(false); err != nil { | |
| 117 | + | return nil, err | |
| 118 | + | } | |
| 119 | + | return c, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (c *Connection) bindOutput(global, version uint32) error { | |
| 123 | + | if version < 4 { | |
| 124 | + | return &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output names require version 4, announced %d", version)} | |
| 125 | + | } | |
| 126 | + | out := core.NewOutput(c.Display.Context()) | |
| 127 | + | if err := c.Display.Registry().Bind(global, core.OutputInterface, 4, out); err != nil { | |
| 128 | + | c.Display.Context().Unregister(out) | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | c.outputs[global] = Output{Global: global, Scale: 1, proxy: out} | |
| 132 | + | out.OnName(func(name string) { c.post(Event{Kind: OutputNamed, Output: Output{Global: global, Name: name}}) }) | |
| 133 | + | out.OnScale(func(scale int32) { c.post(Event{Kind: OutputScale, Output: Output{Global: global, Scale: scale}}) }) | |
| 134 | + | out.OnDone(func() { c.post(Event{Kind: OutputAdded, Output: Output{Global: global}}) }) | |
| 135 | + | return nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (c *Connection) HandleRegistryGlobal(e wlturbo.RegistryGlobalEvent) { | |
| 139 | + | if e.Interface == core.OutputInterface { | |
| 140 | + | c.post(Event{Kind: OutputGlobal, Output: Output{Global: e.Name}, Version: e.Version}) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | func (c *Connection) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) { | |
| 144 | + | c.post(Event{Kind: OutputRemoved, Output: Output{Global: e.Name}}) | |
| 145 | + | } | |
| 146 | + | func (c *Connection) post(ev Event) { | |
| 147 | + | c.gate.Lock() | |
| 148 | + | if c.pausing { | |
| 149 | + | c.parked = append(c.parked, ev) | |
| 150 | + | c.gate.Unlock() | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | sig := c.pauseSig | |
| 154 | + | c.gate.Unlock() | |
| 155 | + | select { | |
| 156 | + | case c.events <- ev: | |
| 157 | + | case <-c.stop: | |
| 158 | + | CloseEventFD(ev) | |
| 159 | + | case <-sig: | |
| 160 | + | c.gate.Lock() | |
| 161 | + | c.parked = append(c.parked, ev) | |
| 162 | + | c.gate.Unlock() | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // pauseReader stops the reader between two Dispatch calls and returns once it | |
| 167 | + | // is parked. The nested calls of one owner goroutine share the first pause; | |
| 168 | + | // the returned func ends it. Without a running reader it does nothing. | |
| 169 | + | func (c *Connection) pauseReader() (resume func()) { | |
| 170 | + | if !c.started.Load() || c.done == nil { | |
| 171 | + | return func() {} | |
| 172 | + | } | |
| 173 | + | c.pauseDepth++ | |
| 174 | + | if c.pauseDepth > 1 { | |
| 175 | + | return func() { c.pauseDepth-- } | |
| 176 | + | } | |
| 177 | + | c.gate.Lock() | |
| 178 | + | c.pausing = true | |
| 179 | + | close(c.pauseSig) | |
| 180 | + | c.gate.Unlock() | |
| 181 | + | if c.socket != nil { | |
| 182 | + | _ = c.socket.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read | |
| 183 | + | } | |
| 184 | + | select { | |
| 185 | + | case <-c.pauseAck: | |
| 186 | + | case <-c.done: | |
| 187 | + | } | |
| 188 | + | if c.socket != nil { | |
| 189 | + | _ = c.socket.SetReadDeadline(time.Time{}) | |
| 190 | + | } | |
| 191 | + | return func() { | |
| 192 | + | c.pauseDepth-- | |
| 193 | + | if c.pauseDepth > 0 { | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | c.gate.Lock() | |
| 197 | + | c.pausing = false | |
| 198 | + | c.pauseSig = make(chan struct{}) | |
| 199 | + | c.gate.Unlock() | |
| 200 | + | select { | |
| 201 | + | case c.resume <- struct{}{}: | |
| 202 | + | default: | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // flushParked re-posts events held while paused; reader goroutine only. | |
| 208 | + | func (c *Connection) flushParked() { | |
| 209 | + | c.gate.Lock() | |
| 210 | + | held := c.parked | |
| 211 | + | c.parked = nil | |
| 212 | + | c.gate.Unlock() | |
| 213 | + | for _, ev := range held { | |
| 214 | + | c.post(ev) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // ErrLockFinished is wrapped by platform errors when the compositor refused or | |
| 219 | + | // ended the session lock (ext_session_lock_v1.finished). | |
| 220 | + | var ErrLockFinished = errors.New("wayland: session lock finished") | |
| 221 | + | ||
| 222 | + | // holdDuringSetup reports events the lock owner must see through its own event | |
| 223 | + | // handling: lock state and output lifetimes. They are never applied by the | |
| 224 | + | // startup drain, which would hide them from the owner. | |
| 225 | + | func holdDuringSetup(k EventKind) bool { | |
| 226 | + | switch k { | |
| 227 | + | case LockAcquired, LockFinished, OutputGlobal, OutputNamed, OutputScale, OutputAdded, OutputRemoved: | |
| 228 | + | return true | |
| 229 | + | } | |
| 230 | + | return false | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // drainStartup applies queued events before the reader starts. With hold set | |
| 234 | + | // (window creation after the lock exists) lock and output events are kept, in | |
| 235 | + | // order, for TakeHeld instead of being applied; a held `finished` fails setup. | |
| 236 | + | func (c *Connection) drainStartup(hold bool) error { | |
| 237 | + | for { | |
| 238 | + | select { | |
| 239 | + | case ev := <-c.events: | |
| 240 | + | if hold && ev.Window == nil && holdDuringSetup(ev.Kind) { | |
| 241 | + | c.held = append(c.held, ev) | |
| 242 | + | if ev.Kind == LockFinished { | |
| 243 | + | c.lockFinish = true // Close then sends the plain destroy | |
| 244 | + | return ErrLockFinished | |
| 245 | + | } | |
| 246 | + | continue | |
| 247 | + | } | |
| 248 | + | if err := c.Apply(ev); err != nil { | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | default: | |
| 252 | + | return nil | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // TakeHeld returns the lock and output events kept during window setup, oldest | |
| 258 | + | // first. The owner feeds them to its normal event handling right after it | |
| 259 | + | // starts the reader, so they precede everything the reader posts later. | |
| 260 | + | func (c *Connection) TakeHeld() []Event { | |
| 261 | + | held := c.held | |
| 262 | + | c.held = nil | |
| 263 | + | return held | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // Outputs returns a copy of owner-applied output lifetimes. Call after applying | |
| 267 | + | // OutputAdded/Removed events; names and scales are never read from the reader. | |
| 268 | + | func (c *Connection) Outputs() []Output { | |
| 269 | + | result := make([]Output, 0, len(c.outputs)) | |
| 270 | + | for _, out := range c.outputs { | |
| 271 | + | if out.Name != "" { | |
| 272 | + | result = append(result, out) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | sort.Slice(result, func(i, j int) bool { return result[i].Global < result[j].Global }) | |
| 276 | + | return result | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // AcquireLock creates the sole lock object for this connection. Locked and | |
| 280 | + | // Finished are transport events, not an authentication or unlock policy. | |
| 281 | + | func (c *Connection) AcquireLock() error { | |
| 282 | + | if c.closed || c.lock != nil { | |
| 283 | + | return errors.New("connection closed or lock already acquired") | |
| 284 | + | } | |
| 285 | + | defer c.pauseReader()() // locked/finished may follow the request immediately | |
| 286 | + | if c.lockManager == nil { | |
| 287 | + | manager := extsessionlock.NewExtSessionLockManager(c.Display.Context()) | |
| 288 | + | if _, err := c.Display.Registry().BindNegotiated(extsessionlock.ExtSessionLockManagerInterface, 1, manager); err != nil { | |
| 289 | + | c.Display.Context().Unregister(manager) | |
| 290 | + | return &CapabilityError{Name: extsessionlock.ExtSessionLockManagerInterface, Cause: err} | |
| 291 | + | } | |
| 292 | + | c.lockManager = manager | |
| 293 | + | } | |
| 294 | + | lock, err := c.lockManager.Lock() | |
| 295 | + | if err != nil { | |
| 296 | + | return err | |
| 297 | + | } | |
| 298 | + | c.lock = lock | |
| 299 | + | lock.OnLocked(func() { c.post(Event{Kind: LockAcquired}) }) | |
| 300 | + | lock.OnFinished(func() { c.post(Event{Kind: LockFinished}) }) | |
| 301 | + | if !c.started.Load() { | |
| 302 | + | // The compositor answers with locked or finished immediately; queue it | |
| 303 | + | // now so window setup can hold it for the owner (see drainStartup). | |
| 304 | + | return c.Display.Roundtrip() | |
| 305 | + | } | |
| 306 | + | return nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // Locked and Finished report the owner-applied lock state. | |
| 310 | + | func (c *Connection) Locked() bool { return c.lockDone && !c.lockFinish } | |
| 311 | + | func (c *Connection) Finished() bool { return c.lockFinish } | |
| 312 | + | ||
| 313 | + | // UnlockAndDestroy sends unlock_and_destroy and roundtrips so the compositor | |
| 314 | + | // has processed it before the caller closes anything. It is only valid after | |
| 315 | + | // the locked event; the platform supplies no authorization policy. The reader | |
| 316 | + | // is paused for the roundtrip, so events it posts are parked, never lost. | |
| 317 | + | func (c *Connection) UnlockAndDestroy() error { | |
| 318 | + | if c.lock == nil { | |
| 319 | + | return errors.New("no session lock") | |
| 320 | + | } | |
| 321 | + | if !c.Locked() { | |
| 322 | + | return errors.New("unlock before the locked event") | |
| 323 | + | } | |
| 324 | + | defer c.pauseReader()() | |
| 325 | + | if err := c.lock.UnlockAndDestroy(); err != nil { | |
| 326 | + | return err | |
| 327 | + | } | |
| 328 | + | c.lock, c.lockDone = nil, false | |
| 329 | + | return c.Display.Roundtrip() | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (c *Connection) StartReader() <-chan Event { | |
| 333 | + | c.once.Do(func() { | |
| 334 | + | c.started.Store(true) | |
| 335 | + | for _, w := range c.windows { | |
| 336 | + | w.readerStarted.Store(true) | |
| 337 | + | } | |
| 338 | + | c.done = make(chan struct{}) | |
| 339 | + | go func() { | |
| 340 | + | defer close(c.done) | |
| 341 | + | for { | |
| 342 | + | select { | |
| 343 | + | case <-c.stop: | |
| 344 | + | return | |
| 345 | + | default: | |
| 346 | + | } | |
| 347 | + | c.gate.Lock() | |
| 348 | + | paused := c.pausing | |
| 349 | + | c.gate.Unlock() | |
| 350 | + | if paused { | |
| 351 | + | c.pauseAck <- struct{}{} | |
| 352 | + | select { | |
| 353 | + | case <-c.resume: | |
| 354 | + | c.flushParked() | |
| 355 | + | continue | |
| 356 | + | case <-c.stop: | |
| 357 | + | return | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | err := c.Display.Dispatch() | |
| 361 | + | if errors.Is(err, os.ErrDeadlineExceeded) { | |
| 362 | + | continue // a pause request interrupted the read | |
| 363 | + | } | |
| 364 | + | if err != nil { | |
| 365 | + | c.post(Event{Kind: TransportError, Err: err}) | |
| 366 | + | return | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | }() | |
| 370 | + | }) | |
| 371 | + | return c.events | |
| 372 | + | } | |
| 373 | + | func (c *Connection) Events() <-chan Event { return c.events } | |
| 374 | + | ||
| 375 | + | // Apply handles connection events or routes a tagged event to its window. | |
| 376 | + | func (c *Connection) Apply(ev Event) error { | |
| 377 | + | if ev.Window != nil { | |
| 378 | + | return ev.Window.Apply(ev) | |
| 379 | + | } | |
| 380 | + | switch ev.Kind { | |
| 381 | + | case OutputGlobal: | |
| 382 | + | if _, exists := c.outputs[ev.Output.Global]; !exists { | |
| 383 | + | defer c.pauseReader()() // name/scale/done follow the bind | |
| 384 | + | return c.bindOutput(ev.Output.Global, ev.Version) | |
| 385 | + | } | |
| 386 | + | case OutputNamed, OutputScale: | |
| 387 | + | out, ok := c.outputs[ev.Output.Global] | |
| 388 | + | if !ok { | |
| 389 | + | return nil | |
| 390 | + | } | |
| 391 | + | if ev.Kind == OutputNamed { | |
| 392 | + | out.Name = ev.Output.Name | |
| 393 | + | } else if ev.Output.Scale > 0 { | |
| 394 | + | out.Scale = ev.Output.Scale | |
| 395 | + | } | |
| 396 | + | c.outputs[out.Global] = out | |
| 397 | + | case OutputAdded: | |
| 398 | + | case OutputRemoved: | |
| 399 | + | if out, ok := c.outputs[ev.Output.Global]; ok { | |
| 400 | + | _ = out.proxy.Release() | |
| 401 | + | delete(c.outputs, ev.Output.Global) | |
| 402 | + | } | |
| 403 | + | case SeatCapabilities, SeatRemoved: | |
| 404 | + | oldPointer := c.seat.Pointer | |
| 405 | + | defer c.pauseReader()() // keymap/capability events follow get_keyboard | |
| 406 | + | if ev.Kind == SeatRemoved || (ev.Capabilities&1 == 0 && oldPointer != nil) { | |
| 407 | + | // Shape devices die before the wl_pointer they were created from | |
| 408 | + | // is released; their enter serials belonged to that pointer. | |
| 409 | + | for _, w := range c.windows { | |
| 410 | + | w.dropCursorDevice() | |
| 411 | + | } | |
| 412 | + | } | |
| 413 | + | if err := c.seat.ApplySeat(ev); err != nil { | |
| 414 | + | return err | |
| 415 | + | } | |
| 416 | + | for _, w := range c.windows { | |
| 417 | + | if oldPointer != c.seat.Pointer { | |
| 418 | + | w.dropCursorDevice() | |
| 419 | + | w.queueInput([]Input{{Kind: "leave"}}) | |
| 420 | + | } | |
| 421 | + | w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard | |
| 422 | + | if w.LockSurface != nil { | |
| 423 | + | w.Seat = nil | |
| 424 | + | } | |
| 425 | + | w.pointerGen = c.seat.pointerGen | |
| 426 | + | if ev.Kind == SeatRemoved || ev.Capabilities&2 == 0 { | |
| 427 | + | w.ownerFocusOut() | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | case InputFocusOut: | |
| 431 | + | for _, w := range c.windows { | |
| 432 | + | w.ownerFocusOut() | |
| 433 | + | } | |
| 434 | + | case TransportError: | |
| 435 | + | return ev.Err | |
| 436 | + | case LockAcquired: | |
| 437 | + | c.lockDone = true | |
| 438 | + | case LockFinished: | |
| 439 | + | c.lockFinish = true | |
| 440 | + | default: | |
| 441 | + | return fmt.Errorf("unrouted connection event %d", ev.Kind) | |
| 442 | + | } | |
| 443 | + | return nil | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (c *Connection) Close() error { | |
| 447 | + | if c == nil || c.closed { | |
| 448 | + | return nil | |
| 449 | + | } | |
| 450 | + | c.closed = true | |
| 451 | + | close(c.stop) | |
| 452 | + | for _, w := range c.windows { | |
| 453 | + | _ = w.Close() | |
| 454 | + | } | |
| 455 | + | if c.lock != nil && c.lockFinish { | |
| 456 | + | // Refused, or ended after locked: the compositor ended the lock, so a | |
| 457 | + | // plain destroy is valid (unlock_and_destroy already cleared c.lock). | |
| 458 | + | _ = c.lock.Destroy() | |
| 459 | + | } | |
| 460 | + | if c.lockManager != nil { | |
| 461 | + | _ = c.lockManager.Destroy() | |
| 462 | + | } | |
| 463 | + | err := c.Display.Close() // closing an acquired owner never requests unlock | |
| 464 | + | if c.done != nil { | |
| 465 | + | <-c.done | |
| 466 | + | } | |
| 467 | + | if c.seat != nil { | |
| 468 | + | _ = c.seat.releaseSeat() | |
| 469 | + | } | |
| 470 | + | for len(c.events) > 0 { | |
| 471 | + | CloseEventFD(<-c.events) | |
| 472 | + | } | |
| 473 | + | for _, ev := range c.held { | |
| 474 | + | CloseEventFD(ev) | |
| 475 | + | } | |
| 476 | + | c.held = nil | |
| 477 | + | c.gate.Lock() | |
| 478 | + | for _, ev := range c.parked { | |
| 479 | + | CloseEventFD(ev) | |
| 480 | + | } | |
| 481 | + | c.parked = nil | |
| 482 | + | c.gate.Unlock() | |
| 483 | + | return err | |
| 484 | + | } | |
| @@ -0,0 +1,484 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "net" | |||
| 10 | + | "os" | |||
| 11 | + | "sort" | |||
| 12 | + | "sync" | |||
| 13 | + | "sync/atomic" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/wlturbo" | |||
| 17 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 18 | + | "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // Connection owns one socket, one reader and one seat. All NewWindow, Apply, | |||
| 22 | + | // Outputs, AcquireLock and Close calls belong to the same UI owner goroutine. | |||
| 23 | + | // Reader callbacks post immutable events; they never mutate owner window state. | |||
| 24 | + | type Connection struct { | |||
| 25 | + | Display *wlturbo.Display | |||
| 26 | + | events chan Event | |||
| 27 | + | stop chan struct{} | |||
| 28 | + | done chan struct{} | |||
| 29 | + | once sync.Once | |||
| 30 | + | started atomic.Bool | |||
| 31 | + | windows map[uint32]*Window | |||
| 32 | + | routes sync.Map // immutable surface->window identities published by owner | |||
| 33 | + | outputs map[uint32]Output | |||
| 34 | + | seat *Window // transport-only seat host, never a rendering window | |||
| 35 | + | lockManager *extsessionlock.ExtSessionLockManager | |||
| 36 | + | lock *extsessionlock.ExtSessionLock | |||
| 37 | + | lockFinish bool // finished event applied | |||
| 38 | + | lockDone bool // locked event applied | |||
| 39 | + | held []Event // lock/output events kept by drainStartup(true); owner only | |||
| 40 | + | closed bool | |||
| 41 | + | ||||
| 42 | + | // Reader pause gate. The owner pauses the reader to register handlers on | |||
| 43 | + | // freshly created objects (whose first events may arrive immediately) or to | |||
| 44 | + | // run its own roundtrip. While paused, post parks events instead of | |||
| 45 | + | // blocking, so neither side can wait on the bounded queue. | |||
| 46 | + | socket net.Conn | |||
| 47 | + | gate sync.Mutex | |||
| 48 | + | pausing bool | |||
| 49 | + | pauseSig chan struct{} // closed when a pause is requested | |||
| 50 | + | parked []Event | |||
| 51 | + | pauseDepth int // owner goroutine only | |||
| 52 | + | pauseAck chan struct{} | |||
| 53 | + | resume chan struct{} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Output identifies the registry lifetime, not merely a reusable monitor name. | |||
| 57 | + | type Output struct { | |||
| 58 | + | Global uint32 | |||
| 59 | + | Name string | |||
| 60 | + | Scale int32 | |||
| 61 | + | proxy *core.Output | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func ConnectConnection(ctx context.Context, name string) (res *Connection, err error) { | |||
| 65 | + | if ctx == nil { | |||
| 66 | + | return nil, errors.New("nil context") | |||
| 67 | + | } | |||
| 68 | + | if err := ctx.Err(); err != nil { | |||
| 69 | + | return nil, err | |||
| 70 | + | } | |||
| 71 | + | path, err := waylandSocketPath(name) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return nil, err | |||
| 74 | + | } | |||
| 75 | + | socket, err := (&net.Dialer{Timeout: dialTimeout}).DialContext(ctx, "unix", path) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return nil, fmt.Errorf("failed to connect to Wayland: %w", err) | |||
| 78 | + | } | |||
| 79 | + | display, err := wlturbo.ConnectFromConn(socket) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return nil, err | |||
| 82 | + | } | |||
| 83 | + | c := &Connection{Display: display, socket: socket, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window), outputs: make(map[uint32]Output), pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)} | |||
| 84 | + | watch := context.AfterFunc(ctx, func() { _ = display.Close() }) | |||
| 85 | + | defer func() { | |||
| 86 | + | if !watch() && err == nil { | |||
| 87 | + | err = ctx.Err() | |||
| 88 | + | } | |||
| 89 | + | if err != nil { | |||
| 90 | + | _ = c.Close() | |||
| 91 | + | res = nil | |||
| 92 | + | if ctx.Err() != nil { | |||
| 93 | + | err = errors.Join(ctx.Err(), err) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 97 | + | if err = display.Roundtrip(); err != nil { | |||
| 98 | + | return nil, err | |||
| 99 | + | } | |||
| 100 | + | c.seat = &Window{Display: display, connection: c, events: c.events, readerStop: c.stop} | |||
| 101 | + | if err = c.seat.BindSeat(); err != nil { | |||
| 102 | + | return nil, err | |||
| 103 | + | } | |||
| 104 | + | display.Registry().AddGlobalHandler(c) | |||
| 105 | + | display.Registry().AddGlobalRemoveHandler(c) | |||
| 106 | + | for name, g := range display.Registry().GetGlobals() { | |||
| 107 | + | if g.Interface == core.OutputInterface { | |||
| 108 | + | if err = c.bindOutput(name, g.Version); err != nil { | |||
| 109 | + | return nil, err | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if err = display.Roundtrip(); err != nil { | |||
| 114 | + | return nil, err | |||
| 115 | + | } | |||
| 116 | + | if err = c.drainStartup(false); err != nil { | |||
| 117 | + | return nil, err | |||
| 118 | + | } | |||
| 119 | + | return c, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (c *Connection) bindOutput(global, version uint32) error { | |||
| 123 | + | if version < 4 { | |||
| 124 | + | return &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output names require version 4, announced %d", version)} | |||
| 125 | + | } | |||
| 126 | + | out := core.NewOutput(c.Display.Context()) | |||
| 127 | + | if err := c.Display.Registry().Bind(global, core.OutputInterface, 4, out); err != nil { | |||
| 128 | + | c.Display.Context().Unregister(out) | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | c.outputs[global] = Output{Global: global, Scale: 1, proxy: out} | |||
| 132 | + | out.OnName(func(name string) { c.post(Event{Kind: OutputNamed, Output: Output{Global: global, Name: name}}) }) | |||
| 133 | + | out.OnScale(func(scale int32) { c.post(Event{Kind: OutputScale, Output: Output{Global: global, Scale: scale}}) }) | |||
| 134 | + | out.OnDone(func() { c.post(Event{Kind: OutputAdded, Output: Output{Global: global}}) }) | |||
| 135 | + | return nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func (c *Connection) HandleRegistryGlobal(e wlturbo.RegistryGlobalEvent) { | |||
| 139 | + | if e.Interface == core.OutputInterface { | |||
| 140 | + | c.post(Event{Kind: OutputGlobal, Output: Output{Global: e.Name}, Version: e.Version}) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | func (c *Connection) HandleRegistryGlobalRemove(e wlturbo.RegistryGlobalRemoveEvent) { | |||
| 144 | + | c.post(Event{Kind: OutputRemoved, Output: Output{Global: e.Name}}) | |||
| 145 | + | } | |||
| 146 | + | func (c *Connection) post(ev Event) { | |||
| 147 | + | c.gate.Lock() | |||
| 148 | + | if c.pausing { | |||
| 149 | + | c.parked = append(c.parked, ev) | |||
| 150 | + | c.gate.Unlock() | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | sig := c.pauseSig | |||
| 154 | + | c.gate.Unlock() | |||
| 155 | + | select { | |||
| 156 | + | case c.events <- ev: | |||
| 157 | + | case <-c.stop: | |||
| 158 | + | CloseEventFD(ev) | |||
| 159 | + | case <-sig: | |||
| 160 | + | c.gate.Lock() | |||
| 161 | + | c.parked = append(c.parked, ev) | |||
| 162 | + | c.gate.Unlock() | |||
| 163 | + | } | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // pauseReader stops the reader between two Dispatch calls and returns once it | |||
| 167 | + | // is parked. The nested calls of one owner goroutine share the first pause; | |||
| 168 | + | // the returned func ends it. Without a running reader it does nothing. | |||
| 169 | + | func (c *Connection) pauseReader() (resume func()) { | |||
| 170 | + | if !c.started.Load() || c.done == nil { | |||
| 171 | + | return func() {} | |||
| 172 | + | } | |||
| 173 | + | c.pauseDepth++ | |||
| 174 | + | if c.pauseDepth > 1 { | |||
| 175 | + | return func() { c.pauseDepth-- } | |||
| 176 | + | } | |||
| 177 | + | c.gate.Lock() | |||
| 178 | + | c.pausing = true | |||
| 179 | + | close(c.pauseSig) | |||
| 180 | + | c.gate.Unlock() | |||
| 181 | + | if c.socket != nil { | |||
| 182 | + | _ = c.socket.SetReadDeadline(time.Unix(1, 0)) // wakes a blocked read | |||
| 183 | + | } | |||
| 184 | + | select { | |||
| 185 | + | case <-c.pauseAck: | |||
| 186 | + | case <-c.done: | |||
| 187 | + | } | |||
| 188 | + | if c.socket != nil { | |||
| 189 | + | _ = c.socket.SetReadDeadline(time.Time{}) | |||
| 190 | + | } | |||
| 191 | + | return func() { | |||
| 192 | + | c.pauseDepth-- | |||
| 193 | + | if c.pauseDepth > 0 { | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | c.gate.Lock() | |||
| 197 | + | c.pausing = false | |||
| 198 | + | c.pauseSig = make(chan struct{}) | |||
| 199 | + | c.gate.Unlock() | |||
| 200 | + | select { | |||
| 201 | + | case c.resume <- struct{}{}: | |||
| 202 | + | default: | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // flushParked re-posts events held while paused; reader goroutine only. | |||
| 208 | + | func (c *Connection) flushParked() { | |||
| 209 | + | c.gate.Lock() | |||
| 210 | + | held := c.parked | |||
| 211 | + | c.parked = nil | |||
| 212 | + | c.gate.Unlock() | |||
| 213 | + | for _, ev := range held { | |||
| 214 | + | c.post(ev) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // ErrLockFinished is wrapped by platform errors when the compositor refused or | |||
| 219 | + | // ended the session lock (ext_session_lock_v1.finished). | |||
| 220 | + | var ErrLockFinished = errors.New("wayland: session lock finished") | |||
| 221 | + | ||||
| 222 | + | // holdDuringSetup reports events the lock owner must see through its own event | |||
| 223 | + | // handling: lock state and output lifetimes. They are never applied by the | |||
| 224 | + | // startup drain, which would hide them from the owner. | |||
| 225 | + | func holdDuringSetup(k EventKind) bool { | |||
| 226 | + | switch k { | |||
| 227 | + | case LockAcquired, LockFinished, OutputGlobal, OutputNamed, OutputScale, OutputAdded, OutputRemoved: | |||
| 228 | + | return true | |||
| 229 | + | } | |||
| 230 | + | return false | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // drainStartup applies queued events before the reader starts. With hold set | |||
| 234 | + | // (window creation after the lock exists) lock and output events are kept, in | |||
| 235 | + | // order, for TakeHeld instead of being applied; a held `finished` fails setup. | |||
| 236 | + | func (c *Connection) drainStartup(hold bool) error { | |||
| 237 | + | for { | |||
| 238 | + | select { | |||
| 239 | + | case ev := <-c.events: | |||
| 240 | + | if hold && ev.Window == nil && holdDuringSetup(ev.Kind) { | |||
| 241 | + | c.held = append(c.held, ev) | |||
| 242 | + | if ev.Kind == LockFinished { | |||
| 243 | + | c.lockFinish = true // Close then sends the plain destroy | |||
| 244 | + | return ErrLockFinished | |||
| 245 | + | } | |||
| 246 | + | continue | |||
| 247 | + | } | |||
| 248 | + | if err := c.Apply(ev); err != nil { | |||
| 249 | + | return err | |||
| 250 | + | } | |||
| 251 | + | default: | |||
| 252 | + | return nil | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // TakeHeld returns the lock and output events kept during window setup, oldest | |||
| 258 | + | // first. The owner feeds them to its normal event handling right after it | |||
| 259 | + | // starts the reader, so they precede everything the reader posts later. | |||
| 260 | + | func (c *Connection) TakeHeld() []Event { | |||
| 261 | + | held := c.held | |||
| 262 | + | c.held = nil | |||
| 263 | + | return held | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // Outputs returns a copy of owner-applied output lifetimes. Call after applying | |||
| 267 | + | // OutputAdded/Removed events; names and scales are never read from the reader. | |||
| 268 | + | func (c *Connection) Outputs() []Output { | |||
| 269 | + | result := make([]Output, 0, len(c.outputs)) | |||
| 270 | + | for _, out := range c.outputs { | |||
| 271 | + | if out.Name != "" { | |||
| 272 | + | result = append(result, out) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | sort.Slice(result, func(i, j int) bool { return result[i].Global < result[j].Global }) | |||
| 276 | + | return result | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // AcquireLock creates the sole lock object for this connection. Locked and | |||
| 280 | + | // Finished are transport events, not an authentication or unlock policy. | |||
| 281 | + | func (c *Connection) AcquireLock() error { | |||
| 282 | + | if c.closed || c.lock != nil { | |||
| 283 | + | return errors.New("connection closed or lock already acquired") | |||
| 284 | + | } | |||
| 285 | + | defer c.pauseReader()() // locked/finished may follow the request immediately | |||
| 286 | + | if c.lockManager == nil { | |||
| 287 | + | manager := extsessionlock.NewExtSessionLockManager(c.Display.Context()) | |||
| 288 | + | if _, err := c.Display.Registry().BindNegotiated(extsessionlock.ExtSessionLockManagerInterface, 1, manager); err != nil { | |||
| 289 | + | c.Display.Context().Unregister(manager) | |||
| 290 | + | return &CapabilityError{Name: extsessionlock.ExtSessionLockManagerInterface, Cause: err} | |||
| 291 | + | } | |||
| 292 | + | c.lockManager = manager | |||
| 293 | + | } | |||
| 294 | + | lock, err := c.lockManager.Lock() | |||
| 295 | + | if err != nil { | |||
| 296 | + | return err | |||
| 297 | + | } | |||
| 298 | + | c.lock = lock | |||
| 299 | + | lock.OnLocked(func() { c.post(Event{Kind: LockAcquired}) }) | |||
| 300 | + | lock.OnFinished(func() { c.post(Event{Kind: LockFinished}) }) | |||
| 301 | + | if !c.started.Load() { | |||
| 302 | + | // The compositor answers with locked or finished immediately; queue it | |||
| 303 | + | // now so window setup can hold it for the owner (see drainStartup). | |||
| 304 | + | return c.Display.Roundtrip() | |||
| 305 | + | } | |||
| 306 | + | return nil | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // Locked and Finished report the owner-applied lock state. | |||
| 310 | + | func (c *Connection) Locked() bool { return c.lockDone && !c.lockFinish } | |||
| 311 | + | func (c *Connection) Finished() bool { return c.lockFinish } | |||
| 312 | + | ||||
| 313 | + | // UnlockAndDestroy sends unlock_and_destroy and roundtrips so the compositor | |||
| 314 | + | // has processed it before the caller closes anything. It is only valid after | |||
| 315 | + | // the locked event; the platform supplies no authorization policy. The reader | |||
| 316 | + | // is paused for the roundtrip, so events it posts are parked, never lost. | |||
| 317 | + | func (c *Connection) UnlockAndDestroy() error { | |||
| 318 | + | if c.lock == nil { | |||
| 319 | + | return errors.New("no session lock") | |||
| 320 | + | } | |||
| 321 | + | if !c.Locked() { | |||
| 322 | + | return errors.New("unlock before the locked event") | |||
| 323 | + | } | |||
| 324 | + | defer c.pauseReader()() | |||
| 325 | + | if err := c.lock.UnlockAndDestroy(); err != nil { | |||
| 326 | + | return err | |||
| 327 | + | } | |||
| 328 | + | c.lock, c.lockDone = nil, false | |||
| 329 | + | return c.Display.Roundtrip() | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func (c *Connection) StartReader() <-chan Event { | |||
| 333 | + | c.once.Do(func() { | |||
| 334 | + | c.started.Store(true) | |||
| 335 | + | for _, w := range c.windows { | |||
| 336 | + | w.readerStarted.Store(true) | |||
| 337 | + | } | |||
| 338 | + | c.done = make(chan struct{}) | |||
| 339 | + | go func() { | |||
| 340 | + | defer close(c.done) | |||
| 341 | + | for { | |||
| 342 | + | select { | |||
| 343 | + | case <-c.stop: | |||
| 344 | + | return | |||
| 345 | + | default: | |||
| 346 | + | } | |||
| 347 | + | c.gate.Lock() | |||
| 348 | + | paused := c.pausing | |||
| 349 | + | c.gate.Unlock() | |||
| 350 | + | if paused { | |||
| 351 | + | c.pauseAck <- struct{}{} | |||
| 352 | + | select { | |||
| 353 | + | case <-c.resume: | |||
| 354 | + | c.flushParked() | |||
| 355 | + | continue | |||
| 356 | + | case <-c.stop: | |||
| 357 | + | return | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | err := c.Display.Dispatch() | |||
| 361 | + | if errors.Is(err, os.ErrDeadlineExceeded) { | |||
| 362 | + | continue // a pause request interrupted the read | |||
| 363 | + | } | |||
| 364 | + | if err != nil { | |||
| 365 | + | c.post(Event{Kind: TransportError, Err: err}) | |||
| 366 | + | return | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | }() | |||
| 370 | + | }) | |||
| 371 | + | return c.events | |||
| 372 | + | } | |||
| 373 | + | func (c *Connection) Events() <-chan Event { return c.events } | |||
| 374 | + | ||||
| 375 | + | // Apply handles connection events or routes a tagged event to its window. | |||
| 376 | + | func (c *Connection) Apply(ev Event) error { | |||
| 377 | + | if ev.Window != nil { | |||
| 378 | + | return ev.Window.Apply(ev) | |||
| 379 | + | } | |||
| 380 | + | switch ev.Kind { | |||
| 381 | + | case OutputGlobal: | |||
| 382 | + | if _, exists := c.outputs[ev.Output.Global]; !exists { | |||
| 383 | + | defer c.pauseReader()() // name/scale/done follow the bind | |||
| 384 | + | return c.bindOutput(ev.Output.Global, ev.Version) | |||
| 385 | + | } | |||
| 386 | + | case OutputNamed, OutputScale: | |||
| 387 | + | out, ok := c.outputs[ev.Output.Global] | |||
| 388 | + | if !ok { | |||
| 389 | + | return nil | |||
| 390 | + | } | |||
| 391 | + | if ev.Kind == OutputNamed { | |||
| 392 | + | out.Name = ev.Output.Name | |||
| 393 | + | } else if ev.Output.Scale > 0 { | |||
| 394 | + | out.Scale = ev.Output.Scale | |||
| 395 | + | } | |||
| 396 | + | c.outputs[out.Global] = out | |||
| 397 | + | case OutputAdded: | |||
| 398 | + | case OutputRemoved: | |||
| 399 | + | if out, ok := c.outputs[ev.Output.Global]; ok { | |||
| 400 | + | _ = out.proxy.Release() | |||
| 401 | + | delete(c.outputs, ev.Output.Global) | |||
| 402 | + | } | |||
| 403 | + | case SeatCapabilities, SeatRemoved: | |||
| 404 | + | oldPointer := c.seat.Pointer | |||
| 405 | + | defer c.pauseReader()() // keymap/capability events follow get_keyboard | |||
| 406 | + | if ev.Kind == SeatRemoved || (ev.Capabilities&1 == 0 && oldPointer != nil) { | |||
| 407 | + | // Shape devices die before the wl_pointer they were created from | |||
| 408 | + | // is released; their enter serials belonged to that pointer. | |||
| 409 | + | for _, w := range c.windows { | |||
| 410 | + | w.dropCursorDevice() | |||
| 411 | + | } | |||
| 412 | + | } | |||
| 413 | + | if err := c.seat.ApplySeat(ev); err != nil { | |||
| 414 | + | return err | |||
| 415 | + | } | |||
| 416 | + | for _, w := range c.windows { | |||
| 417 | + | if oldPointer != c.seat.Pointer { | |||
| 418 | + | w.dropCursorDevice() | |||
| 419 | + | w.queueInput([]Input{{Kind: "leave"}}) | |||
| 420 | + | } | |||
| 421 | + | w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard | |||
| 422 | + | if w.LockSurface != nil { | |||
| 423 | + | w.Seat = nil | |||
| 424 | + | } | |||
| 425 | + | w.pointerGen = c.seat.pointerGen | |||
| 426 | + | if ev.Kind == SeatRemoved || ev.Capabilities&2 == 0 { | |||
| 427 | + | w.ownerFocusOut() | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | case InputFocusOut: | |||
| 431 | + | for _, w := range c.windows { | |||
| 432 | + | w.ownerFocusOut() | |||
| 433 | + | } | |||
| 434 | + | case TransportError: | |||
| 435 | + | return ev.Err | |||
| 436 | + | case LockAcquired: | |||
| 437 | + | c.lockDone = true | |||
| 438 | + | case LockFinished: | |||
| 439 | + | c.lockFinish = true | |||
| 440 | + | default: | |||
| 441 | + | return fmt.Errorf("unrouted connection event %d", ev.Kind) | |||
| 442 | + | } | |||
| 443 | + | return nil | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func (c *Connection) Close() error { | |||
| 447 | + | if c == nil || c.closed { | |||
| 448 | + | return nil | |||
| 449 | + | } | |||
| 450 | + | c.closed = true | |||
| 451 | + | close(c.stop) | |||
| 452 | + | for _, w := range c.windows { | |||
| 453 | + | _ = w.Close() | |||
| 454 | + | } | |||
| 455 | + | if c.lock != nil && c.lockFinish { | |||
| 456 | + | // Refused, or ended after locked: the compositor ended the lock, so a | |||
| 457 | + | // plain destroy is valid (unlock_and_destroy already cleared c.lock). | |||
| 458 | + | _ = c.lock.Destroy() | |||
| 459 | + | } | |||
| 460 | + | if c.lockManager != nil { | |||
| 461 | + | _ = c.lockManager.Destroy() | |||
| 462 | + | } | |||
| 463 | + | err := c.Display.Close() // closing an acquired owner never requests unlock | |||
| 464 | + | if c.done != nil { | |||
| 465 | + | <-c.done | |||
| 466 | + | } | |||
| 467 | + | if c.seat != nil { | |||
| 468 | + | _ = c.seat.releaseSeat() | |||
| 469 | + | } | |||
| 470 | + | for len(c.events) > 0 { | |||
| 471 | + | CloseEventFD(<-c.events) | |||
| 472 | + | } | |||
| 473 | + | for _, ev := range c.held { | |||
| 474 | + | CloseEventFD(ev) | |||
| 475 | + | } | |||
| 476 | + | c.held = nil | |||
| 477 | + | c.gate.Lock() | |||
| 478 | + | for _, ev := range c.parked { | |||
| 479 | + | CloseEventFD(ev) | |||
| 480 | + | } | |||
| 481 | + | c.parked = nil | |||
| 482 | + | c.gate.Unlock() | |||
| 483 | + | return err | |||
| 484 | + | } | |||
A internal/platform/wayland/connection_input.go +205 -0
| @@ -0,0 +1,205 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "sync" | |
| 7 | + | "syscall" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 10 | + | "github.com/bnema/wlturbo/wl" | |
| 11 | + | "golang.org/x/sys/unix" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // A cache belongs to exactly one keyboard generation. Dup/replace/close share | |
| 15 | + | // its FD mutex, not a window-state mutex, so capability loss cannot close an FD | |
| 16 | + | // while a reader callback duplicates it. Retired callbacks are inert. | |
| 17 | + | type keyboardMapCache struct { | |
| 18 | + | mu sync.Mutex | |
| 19 | + | fd int | |
| 20 | + | closed bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func (m *keyboardMapCache) replace(fd int) { | |
| 24 | + | m.mu.Lock() | |
| 25 | + | defer m.mu.Unlock() | |
| 26 | + | if m.closed { | |
| 27 | + | _ = syscall.Close(fd) | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | if m.fd >= 0 { | |
| 31 | + | _ = syscall.Close(m.fd) | |
| 32 | + | } | |
| 33 | + | m.fd = fd | |
| 34 | + | } | |
| 35 | + | func (m *keyboardMapCache) duplicate() (int, error) { | |
| 36 | + | m.mu.Lock() | |
| 37 | + | defer m.mu.Unlock() | |
| 38 | + | if m.closed || m.fd < 0 { | |
| 39 | + | return -1, nil | |
| 40 | + | } | |
| 41 | + | // Close-on-exec: the map must not leak into any child process. | |
| 42 | + | return unix.FcntlInt(uintptr(m.fd), unix.F_DUPFD_CLOEXEC, 0) | |
| 43 | + | } | |
| 44 | + | func (m *keyboardMapCache) close() { | |
| 45 | + | m.mu.Lock() | |
| 46 | + | defer m.mu.Unlock() | |
| 47 | + | if !m.closed && m.fd >= 0 { | |
| 48 | + | _ = syscall.Close(m.fd) | |
| 49 | + | } | |
| 50 | + | m.fd = -1 | |
| 51 | + | m.closed = true | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (c *Connection) route(id uint32, ev Event) { | |
| 55 | + | if value, ok := c.routes.Load(id); ok { | |
| 56 | + | ev.Window = value.(*Window) | |
| 57 | + | c.post(ev) | |
| 58 | + | } else { | |
| 59 | + | CloseEventFD(ev) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Focus and frame accumulators below are exclusively reader-owned. They carry | |
| 64 | + | // surface IDs, never pointers to mutable UI/window state. | |
| 65 | + | func (w *Window) watchSharedPointer(p *core.Pointer) { | |
| 66 | + | c := w.connection | |
| 67 | + | w.pointerGen++ | |
| 68 | + | generation, version := w.pointerGen, w.seatVersion | |
| 69 | + | var focus uint32 | |
| 70 | + | var x, y float64 | |
| 71 | + | var frame []Input | |
| 72 | + | var source uint32 | |
| 73 | + | flush := func() { | |
| 74 | + | if len(frame) > 0 { | |
| 75 | + | c.route(focus, Event{Kind: InputFrame, Inputs: frame}) | |
| 76 | + | frame = nil | |
| 77 | + | } | |
| 78 | + | source = 0 | |
| 79 | + | } | |
| 80 | + | add := func(in Input) { | |
| 81 | + | in.AxisSource = source | |
| 82 | + | frame = append(frame, in) | |
| 83 | + | if version < 5 { | |
| 84 | + | flush() | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | p.OnEnter(func(serial, id uint32, px, py wl.Fixed) { | |
| 88 | + | flush() | |
| 89 | + | focus = id | |
| 90 | + | x, y = px.Float64(), py.Float64() | |
| 91 | + | add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: generation}) | |
| 92 | + | }) | |
| 93 | + | p.OnLeave(func(_ uint32, id uint32) { | |
| 94 | + | if focus == id { | |
| 95 | + | add(Input{Kind: "leave"}) | |
| 96 | + | flush() | |
| 97 | + | focus = 0 | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | p.OnMotion(func(_ uint32, px, py wl.Fixed) { | |
| 101 | + | if focus != 0 { | |
| 102 | + | x, y = px.Float64(), py.Float64() | |
| 103 | + | add(Input{Kind: "motion", X: x, Y: y}) | |
| 104 | + | } | |
| 105 | + | }) | |
| 106 | + | p.OnButton(func(serial, _, button, state uint32) { | |
| 107 | + | if focus != 0 { | |
| 108 | + | kind := "release" | |
| 109 | + | if state == 1 { | |
| 110 | + | kind = "press" | |
| 111 | + | } | |
| 112 | + | add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial}) | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | p.OnAxisSource(func(value uint32) { source = value }) | |
| 116 | + | p.OnAxis(func(_, axis uint32, value wl.Fixed) { | |
| 117 | + | if focus != 0 { | |
| 118 | + | in := Input{Kind: "axis", X: x, Y: y, Axis: axis} | |
| 119 | + | if axis == 0 { | |
| 120 | + | in.DY = value.Float64() | |
| 121 | + | } else { | |
| 122 | + | in.DX = value.Float64() | |
| 123 | + | } | |
| 124 | + | add(in) | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | p.OnAxisValue120(func(axis uint32, value int32) { | |
| 128 | + | if focus != 0 { | |
| 129 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: value}) | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | p.OnAxisStop(func(_, axis uint32) { | |
| 133 | + | if focus != 0 { | |
| 134 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true}) | |
| 135 | + | } | |
| 136 | + | }) | |
| 137 | + | p.OnFrame(flush) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (w *Window) watchSharedKeyboard(k *core.Keyboard) { | |
| 141 | + | c := w.connection | |
| 142 | + | var focus uint32 | |
| 143 | + | cache := &keyboardMapCache{fd: -1} | |
| 144 | + | if w.keymapCache != nil { | |
| 145 | + | w.keymapCache.close() // one live cache per keyboard generation | |
| 146 | + | } | |
| 147 | + | w.keymapCache = cache | |
| 148 | + | var format, size uint32 | |
| 149 | + | var mods [4]uint32 | |
| 150 | + | var rate, delay int32 | |
| 151 | + | // Keep one reader-owned duplicate to initialize windows created on hotplug. | |
| 152 | + | // Its lifetime ends when this keyboard proxy or the connection is released. | |
| 153 | + | sendMap := func(id uint32) { | |
| 154 | + | fd, err := cache.duplicate() | |
| 155 | + | if err != nil { | |
| 156 | + | c.post(Event{Kind: TransportError, Err: err}) | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | if fd >= 0 { | |
| 160 | + | c.route(id, Event{Kind: InputKeymap, FD: fd, Format: format, Size: size}) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | k.OnKeymap(func(f uint32, fd *wl.OwnedFD, n uint32) { | |
| 164 | + | next, err := fd.Take() | |
| 165 | + | if err != nil { | |
| 166 | + | c.post(Event{Kind: TransportError, Err: err}) | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | cache.replace(next) | |
| 170 | + | format, size = f, n | |
| 171 | + | if focus != 0 { | |
| 172 | + | sendMap(focus) | |
| 173 | + | } | |
| 174 | + | }) | |
| 175 | + | k.OnEnter(func(serial, id uint32, _ []byte) { | |
| 176 | + | focus = id | |
| 177 | + | sendMap(id) | |
| 178 | + | c.route(id, Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay}) | |
| 179 | + | c.route(id, Event{Kind: InputModifiers, Mods: mods}) | |
| 180 | + | c.route(id, Event{Kind: InputFocusIn, Serial: serial}) | |
| 181 | + | }) | |
| 182 | + | k.OnLeave(func(_, id uint32) { | |
| 183 | + | c.route(id, Event{Kind: InputFocusOut}) | |
| 184 | + | if focus == id { | |
| 185 | + | focus = 0 | |
| 186 | + | } | |
| 187 | + | }) | |
| 188 | + | k.OnKey(func(serial, _, key, state uint32) { | |
| 189 | + | if focus != 0 { | |
| 190 | + | c.route(focus, Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1}) | |
| 191 | + | } | |
| 192 | + | }) | |
| 193 | + | k.OnModifiers(func(_, depressed, latched, locked, group uint32) { | |
| 194 | + | mods = [4]uint32{depressed, latched, locked, group} | |
| 195 | + | if focus != 0 { | |
| 196 | + | c.route(focus, Event{Kind: InputModifiers, Mods: mods}) | |
| 197 | + | } | |
| 198 | + | }) | |
| 199 | + | k.OnRepeatInfo(func(r, d int32) { | |
| 200 | + | rate, delay = r, d | |
| 201 | + | if focus != 0 { | |
| 202 | + | c.route(focus, Event{Kind: InputRepeatInfo, Rate: r, Delay: d}) | |
| 203 | + | } | |
| 204 | + | }) | |
| 205 | + | } | |
| @@ -0,0 +1,205 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "sync" | |||
| 7 | + | "syscall" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 10 | + | "github.com/bnema/wlturbo/wl" | |||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // A cache belongs to exactly one keyboard generation. Dup/replace/close share | |||
| 15 | + | // its FD mutex, not a window-state mutex, so capability loss cannot close an FD | |||
| 16 | + | // while a reader callback duplicates it. Retired callbacks are inert. | |||
| 17 | + | type keyboardMapCache struct { | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | fd int | |||
| 20 | + | closed bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (m *keyboardMapCache) replace(fd int) { | |||
| 24 | + | m.mu.Lock() | |||
| 25 | + | defer m.mu.Unlock() | |||
| 26 | + | if m.closed { | |||
| 27 | + | _ = syscall.Close(fd) | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | if m.fd >= 0 { | |||
| 31 | + | _ = syscall.Close(m.fd) | |||
| 32 | + | } | |||
| 33 | + | m.fd = fd | |||
| 34 | + | } | |||
| 35 | + | func (m *keyboardMapCache) duplicate() (int, error) { | |||
| 36 | + | m.mu.Lock() | |||
| 37 | + | defer m.mu.Unlock() | |||
| 38 | + | if m.closed || m.fd < 0 { | |||
| 39 | + | return -1, nil | |||
| 40 | + | } | |||
| 41 | + | // Close-on-exec: the map must not leak into any child process. | |||
| 42 | + | return unix.FcntlInt(uintptr(m.fd), unix.F_DUPFD_CLOEXEC, 0) | |||
| 43 | + | } | |||
| 44 | + | func (m *keyboardMapCache) close() { | |||
| 45 | + | m.mu.Lock() | |||
| 46 | + | defer m.mu.Unlock() | |||
| 47 | + | if !m.closed && m.fd >= 0 { | |||
| 48 | + | _ = syscall.Close(m.fd) | |||
| 49 | + | } | |||
| 50 | + | m.fd = -1 | |||
| 51 | + | m.closed = true | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (c *Connection) route(id uint32, ev Event) { | |||
| 55 | + | if value, ok := c.routes.Load(id); ok { | |||
| 56 | + | ev.Window = value.(*Window) | |||
| 57 | + | c.post(ev) | |||
| 58 | + | } else { | |||
| 59 | + | CloseEventFD(ev) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Focus and frame accumulators below are exclusively reader-owned. They carry | |||
| 64 | + | // surface IDs, never pointers to mutable UI/window state. | |||
| 65 | + | func (w *Window) watchSharedPointer(p *core.Pointer) { | |||
| 66 | + | c := w.connection | |||
| 67 | + | w.pointerGen++ | |||
| 68 | + | generation, version := w.pointerGen, w.seatVersion | |||
| 69 | + | var focus uint32 | |||
| 70 | + | var x, y float64 | |||
| 71 | + | var frame []Input | |||
| 72 | + | var source uint32 | |||
| 73 | + | flush := func() { | |||
| 74 | + | if len(frame) > 0 { | |||
| 75 | + | c.route(focus, Event{Kind: InputFrame, Inputs: frame}) | |||
| 76 | + | frame = nil | |||
| 77 | + | } | |||
| 78 | + | source = 0 | |||
| 79 | + | } | |||
| 80 | + | add := func(in Input) { | |||
| 81 | + | in.AxisSource = source | |||
| 82 | + | frame = append(frame, in) | |||
| 83 | + | if version < 5 { | |||
| 84 | + | flush() | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | p.OnEnter(func(serial, id uint32, px, py wl.Fixed) { | |||
| 88 | + | flush() | |||
| 89 | + | focus = id | |||
| 90 | + | x, y = px.Float64(), py.Float64() | |||
| 91 | + | add(Input{Kind: "motion", X: x, Y: y, Enter: true, Serial: serial, PointerGen: generation}) | |||
| 92 | + | }) | |||
| 93 | + | p.OnLeave(func(_ uint32, id uint32) { | |||
| 94 | + | if focus == id { | |||
| 95 | + | add(Input{Kind: "leave"}) | |||
| 96 | + | flush() | |||
| 97 | + | focus = 0 | |||
| 98 | + | } | |||
| 99 | + | }) | |||
| 100 | + | p.OnMotion(func(_ uint32, px, py wl.Fixed) { | |||
| 101 | + | if focus != 0 { | |||
| 102 | + | x, y = px.Float64(), py.Float64() | |||
| 103 | + | add(Input{Kind: "motion", X: x, Y: y}) | |||
| 104 | + | } | |||
| 105 | + | }) | |||
| 106 | + | p.OnButton(func(serial, _, button, state uint32) { | |||
| 107 | + | if focus != 0 { | |||
| 108 | + | kind := "release" | |||
| 109 | + | if state == 1 { | |||
| 110 | + | kind = "press" | |||
| 111 | + | } | |||
| 112 | + | add(Input{Kind: kind, X: x, Y: y, Button: button, Serial: serial}) | |||
| 113 | + | } | |||
| 114 | + | }) | |||
| 115 | + | p.OnAxisSource(func(value uint32) { source = value }) | |||
| 116 | + | p.OnAxis(func(_, axis uint32, value wl.Fixed) { | |||
| 117 | + | if focus != 0 { | |||
| 118 | + | in := Input{Kind: "axis", X: x, Y: y, Axis: axis} | |||
| 119 | + | if axis == 0 { | |||
| 120 | + | in.DY = value.Float64() | |||
| 121 | + | } else { | |||
| 122 | + | in.DX = value.Float64() | |||
| 123 | + | } | |||
| 124 | + | add(in) | |||
| 125 | + | } | |||
| 126 | + | }) | |||
| 127 | + | p.OnAxisValue120(func(axis uint32, value int32) { | |||
| 128 | + | if focus != 0 { | |||
| 129 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, Value120: value}) | |||
| 130 | + | } | |||
| 131 | + | }) | |||
| 132 | + | p.OnAxisStop(func(_, axis uint32) { | |||
| 133 | + | if focus != 0 { | |||
| 134 | + | add(Input{Kind: "axis", X: x, Y: y, Axis: axis, AxisStop: true}) | |||
| 135 | + | } | |||
| 136 | + | }) | |||
| 137 | + | p.OnFrame(flush) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func (w *Window) watchSharedKeyboard(k *core.Keyboard) { | |||
| 141 | + | c := w.connection | |||
| 142 | + | var focus uint32 | |||
| 143 | + | cache := &keyboardMapCache{fd: -1} | |||
| 144 | + | if w.keymapCache != nil { | |||
| 145 | + | w.keymapCache.close() // one live cache per keyboard generation | |||
| 146 | + | } | |||
| 147 | + | w.keymapCache = cache | |||
| 148 | + | var format, size uint32 | |||
| 149 | + | var mods [4]uint32 | |||
| 150 | + | var rate, delay int32 | |||
| 151 | + | // Keep one reader-owned duplicate to initialize windows created on hotplug. | |||
| 152 | + | // Its lifetime ends when this keyboard proxy or the connection is released. | |||
| 153 | + | sendMap := func(id uint32) { | |||
| 154 | + | fd, err := cache.duplicate() | |||
| 155 | + | if err != nil { | |||
| 156 | + | c.post(Event{Kind: TransportError, Err: err}) | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | if fd >= 0 { | |||
| 160 | + | c.route(id, Event{Kind: InputKeymap, FD: fd, Format: format, Size: size}) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | k.OnKeymap(func(f uint32, fd *wl.OwnedFD, n uint32) { | |||
| 164 | + | next, err := fd.Take() | |||
| 165 | + | if err != nil { | |||
| 166 | + | c.post(Event{Kind: TransportError, Err: err}) | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | cache.replace(next) | |||
| 170 | + | format, size = f, n | |||
| 171 | + | if focus != 0 { | |||
| 172 | + | sendMap(focus) | |||
| 173 | + | } | |||
| 174 | + | }) | |||
| 175 | + | k.OnEnter(func(serial, id uint32, _ []byte) { | |||
| 176 | + | focus = id | |||
| 177 | + | sendMap(id) | |||
| 178 | + | c.route(id, Event{Kind: InputRepeatInfo, Rate: rate, Delay: delay}) | |||
| 179 | + | c.route(id, Event{Kind: InputModifiers, Mods: mods}) | |||
| 180 | + | c.route(id, Event{Kind: InputFocusIn, Serial: serial}) | |||
| 181 | + | }) | |||
| 182 | + | k.OnLeave(func(_, id uint32) { | |||
| 183 | + | c.route(id, Event{Kind: InputFocusOut}) | |||
| 184 | + | if focus == id { | |||
| 185 | + | focus = 0 | |||
| 186 | + | } | |||
| 187 | + | }) | |||
| 188 | + | k.OnKey(func(serial, _, key, state uint32) { | |||
| 189 | + | if focus != 0 { | |||
| 190 | + | c.route(focus, Event{Kind: InputKey, Serial: serial, Code: key, Pressed: state == 1}) | |||
| 191 | + | } | |||
| 192 | + | }) | |||
| 193 | + | k.OnModifiers(func(_, depressed, latched, locked, group uint32) { | |||
| 194 | + | mods = [4]uint32{depressed, latched, locked, group} | |||
| 195 | + | if focus != 0 { | |||
| 196 | + | c.route(focus, Event{Kind: InputModifiers, Mods: mods}) | |||
| 197 | + | } | |||
| 198 | + | }) | |||
| 199 | + | k.OnRepeatInfo(func(r, d int32) { | |||
| 200 | + | rate, delay = r, d | |||
| 201 | + | if focus != 0 { | |||
| 202 | + | c.route(focus, Event{Kind: InputRepeatInfo, Rate: r, Delay: d}) | |||
| 203 | + | } | |||
| 204 | + | }) | |||
| 205 | + | } | |||
A internal/platform/wayland/connection_test.go +581 -0
| @@ -0,0 +1,581 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "encoding/binary" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 11 | + | "golang.org/x/sys/unix" | |
| 12 | + | "net" | |
| 13 | + | "os" | |
| 14 | + | "strings" | |
| 15 | + | "syscall" | |
| 16 | + | "testing" | |
| 17 | + | "time" | |
| 18 | + | ||
| 19 | + | "github.com/bnema/wlturbo" | |
| 20 | + | "github.com/bnema/wlturbo/protocol/core" | |
| 21 | + | "github.com/bnema/wlturbo/protocol/cursorshape" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func TestSharedConnectionKeyboardRoutesOnlyFocusedWindow(t *testing.T) { | |
| 25 | + | ca, server := inputPair(t) | |
| 26 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | defer d.Close() | |
| 31 | + | inputRequest(t, server) | |
| 32 | + | c := &Connection{Display: d, events: make(chan Event, 32), stop: make(chan struct{}), windows: make(map[uint32]*Window)} | |
| 33 | + | host := &Window{connection: c, seatVersion: 9} | |
| 34 | + | seat := core.NewSeat(d.Context()) | |
| 35 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | inputRequest(t, server) | |
| 39 | + | keyboard, err := seat.GetKeyboard() | |
| 40 | + | if err != nil { | |
| 41 | + | t.Fatal(err) | |
| 42 | + | } | |
| 43 | + | inputRequest(t, server) | |
| 44 | + | first, second := &Window{connection: c}, &Window{connection: c} | |
| 45 | + | c.routes.Store(uint32(30), first) | |
| 46 | + | c.routes.Store(uint32(31), second) | |
| 47 | + | host.watchSharedKeyboard(keyboard) | |
| 48 | + | // enter includes a length-prefixed empty key array, then key down. | |
| 49 | + | for _, msg := range [][]byte{inputFrame(keyboard.ID(), 1, 1, 30, 0), inputFrame(keyboard.ID(), 3, 2, 0, 30, 1), inputFrame(keyboard.ID(), 2, 3, 30), inputFrame(keyboard.ID(), 1, 4, 31, 0), inputFrame(keyboard.ID(), 3, 5, 0, 48, 1)} { | |
| 50 | + | inputSend(t, server, msg) | |
| 51 | + | if err := d.Dispatch(); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | var keys []Event | |
| 56 | + | for len(c.events) > 0 { | |
| 57 | + | ev := <-c.events | |
| 58 | + | if ev.Kind == InputKey { | |
| 59 | + | keys = append(keys, ev) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if len(keys) != 2 || keys[0].Window != first || keys[0].Code != 30 || keys[1].Window != second || keys[1].Code != 48 { | |
| 63 | + | t.Fatalf("keys: %+v", keys) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestHarnessConnectionLock(t *testing.T) { | |
| 68 | + | out, _ := runLayerHarness(t, "TestHarnessConnectionLockClient") | |
| 69 | + | for _, want := range []string{"LOCK acquired", "LOCK exact configure", "LOCK shared display", "LOCK sibling survived", "LOCK unlock", "LOCK manager reused", "LOCK named layer sibling"} { | |
| 70 | + | if !strings.Contains(out, want) { | |
| 71 | + | t.Fatalf("missing %q in %s", want, out) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | func TestHarnessConnectionLockClient(t *testing.T) { | |
| 76 | + | harnessChild(t) | |
| 77 | + | ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) | |
| 78 | + | defer cancel() | |
| 79 | + | c, err := ConnectConnection(ctx, "") | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | defer c.Close() | |
| 84 | + | outputs := c.Outputs() | |
| 85 | + | if len(outputs) != 1 { | |
| 86 | + | t.Fatalf("outputs %+v", outputs) | |
| 87 | + | } | |
| 88 | + | if err := c.AcquireLock(); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | events := c.StartReader() | |
| 92 | + | wait := func(want EventKind) { | |
| 93 | + | t.Helper() | |
| 94 | + | for { | |
| 95 | + | select { | |
| 96 | + | case ev := <-events: | |
| 97 | + | if err := c.Apply(ev); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | if ev.Kind == want { | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | case <-ctx.Done(): | |
| 104 | + | t.Fatal("event timeout") | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | wait(LockAcquired) | |
| 109 | + | fmt.Println("LOCK acquired") | |
| 110 | + | first, err := c.NewWindow(ctx, 1, 1, false, SurfaceOptions{Lock: &LockOptions{Output: outputs[0].Global}}) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | for !first.Configured || !first.FeedbackDone || first.MainDevice == 0 || len(first.Tranches) == 0 { | |
| 115 | + | select { | |
| 116 | + | case ev := <-events: | |
| 117 | + | if err := c.Apply(ev); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | case <-ctx.Done(): | |
| 121 | + | t.Fatal("lock configure timeout") | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if first.Width != 400 || first.Height != 300 || first.LockSurface == nil || first.XdgSurface != nil || first.LayerSurface != nil { | |
| 125 | + | t.Fatalf("lock geometry/roles: %dx%d", first.Width, first.Height) | |
| 126 | + | } | |
| 127 | + | fmt.Println("LOCK exact configure") | |
| 128 | + | // An ordinary sibling shares the transport, but cannot acquire another lock. | |
| 129 | + | second, err := c.NewWindow(ctx, 20, 20, false, SurfaceOptions{}) | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | if first.Display != second.Display || first.Display != c.Display || first.Seat != nil { | |
| 134 | + | t.Fatal("independent display or lock exposed clipboard/IME seat") | |
| 135 | + | } | |
| 136 | + | fmt.Println("LOCK shared display") | |
| 137 | + | if err := first.Close(); err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | if second.Display == nil || c.closed { | |
| 141 | + | t.Fatal("window close destroyed shared connection") | |
| 142 | + | } | |
| 143 | + | if err := second.SetTitle("sibling survives"); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | fmt.Println("LOCK sibling survived") | |
| 147 | + | if err := c.UnlockAndDestroy(); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 149 | + | } | |
| 150 | + | fmt.Println("LOCK unlock") | |
| 151 | + | manager := c.lockManager | |
| 152 | + | for range 2 { | |
| 153 | + | if err := c.AcquireLock(); err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | wait(LockAcquired) | |
| 157 | + | if c.lockManager != manager { | |
| 158 | + | t.Fatal("lock manager leaked across cycles") | |
| 159 | + | } | |
| 160 | + | if err := c.UnlockAndDestroy(); err != nil { | |
| 161 | + | t.Fatal(err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | fmt.Println("LOCK manager reused") | |
| 165 | + | layer, err := c.NewWindow(ctx, 20, 20, true, SurfaceOptions{Layer: &LayerOptions{Output: outputs[0].Name, Layer: LayerTop, Namespace: "named-sibling"}}) | |
| 166 | + | if err != nil { | |
| 167 | + | t.Fatal(err) | |
| 168 | + | } | |
| 169 | + | for !layer.Configured || !layer.FeedbackDone { | |
| 170 | + | select { | |
| 171 | + | case ev := <-events: | |
| 172 | + | if err := c.Apply(ev); err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | case <-ctx.Done(): | |
| 176 | + | t.Fatal("named sibling timeout") | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | if layer.layerOutputGlobal != outputs[0].Global { | |
| 180 | + | t.Fatal("named sibling selected wrong output") | |
| 181 | + | } | |
| 182 | + | fmt.Println("LOCK named layer sibling") | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestOwnerFocusResetWithFullReaderQueue(t *testing.T) { | |
| 186 | + | c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{1: {}}} | |
| 187 | + | for range cap(c.events) { | |
| 188 | + | c.events <- Event{Kind: LockAcquired} | |
| 189 | + | } | |
| 190 | + | done := make(chan error, 1) | |
| 191 | + | go func() { done <- c.Apply(Event{Kind: InputFocusOut}) }() | |
| 192 | + | select { | |
| 193 | + | case err := <-done: | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatal(err) | |
| 196 | + | } | |
| 197 | + | case <-time.After(time.Second): | |
| 198 | + | close(c.stop) | |
| 199 | + | t.Fatal("owner reset self-posted into full reader queue") | |
| 200 | + | } | |
| 201 | + | if len(c.events) != cap(c.events) { | |
| 202 | + | t.Fatal("owner reset consumed reader events") | |
| 203 | + | } | |
| 204 | + | inputs := c.windows[1].DrainInput(nil) | |
| 205 | + | if len(inputs) != 1 || inputs[0].Kind != "focus-out" { | |
| 206 | + | t.Fatalf("inputs %+v", inputs) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func TestKeyboardMapCacheRetiresEveryGeneration(t *testing.T) { | |
| 211 | + | for range 4 { | |
| 212 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 213 | + | if err != nil { | |
| 214 | + | t.Fatal(err) | |
| 215 | + | } | |
| 216 | + | fd, err := syscall.Dup(int(f.Fd())) | |
| 217 | + | if err != nil { | |
| 218 | + | t.Fatal(err) | |
| 219 | + | } | |
| 220 | + | f.Close() | |
| 221 | + | cache := &keyboardMapCache{fd: -1} | |
| 222 | + | cache.replace(fd) | |
| 223 | + | duplicate, err := cache.duplicate() | |
| 224 | + | if err != nil || duplicate < 0 { | |
| 225 | + | t.Fatalf("duplicate %d %v", duplicate, err) | |
| 226 | + | } | |
| 227 | + | if flags, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil || flags&unix.FD_CLOEXEC == 0 { | |
| 228 | + | t.Fatalf("delivered keymap duplicate is inheritable: flags %d err %v", flags, err) | |
| 229 | + | } | |
| 230 | + | cache.close() | |
| 231 | + | cache.close() | |
| 232 | + | if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, syscall.EBADF) { | |
| 233 | + | t.Fatalf("retired map FD still open: %v", err) | |
| 234 | + | } | |
| 235 | + | if _, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil { | |
| 236 | + | t.Fatal("retired source closed delivered duplicate") | |
| 237 | + | } | |
| 238 | + | syscall.Close(duplicate) | |
| 239 | + | if got, err := cache.duplicate(); got != -1 || err != nil { | |
| 240 | + | t.Fatal("retired callback duplicated map") | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestFeedbackReadinessWaitsForCompleteEvent(t *testing.T) { | |
| 246 | + | w := &Window{Configured: true} | |
| 247 | + | if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 1}); err != nil { | |
| 248 | + | t.Fatal(err) | |
| 249 | + | } | |
| 250 | + | if err := w.Apply(Event{Kind: FeedbackTranche, Tranche: []linuxdmabuf.FormatEntry{{}}}); err != nil { | |
| 251 | + | t.Fatal(err) | |
| 252 | + | } | |
| 253 | + | if w.FeedbackDone { | |
| 254 | + | t.Fatal("partial feedback treated as complete") | |
| 255 | + | } | |
| 256 | + | if err := w.Apply(Event{Kind: FeedbackFormats, Formats: []linuxdmabuf.FormatEntry{{}}}); err != nil { | |
| 257 | + | t.Fatal(err) | |
| 258 | + | } | |
| 259 | + | if w.FeedbackDone { | |
| 260 | + | t.Fatal("table alone treated as complete") | |
| 261 | + | } | |
| 262 | + | if err := w.Apply(Event{Kind: FeedbackComplete}); err != nil || !w.FeedbackDone { | |
| 263 | + | t.Fatal("done not applied") | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // sharedSeat builds a Connection whose seat host is bound over a socketpair. | |
| 268 | + | func sharedSeat(t *testing.T) (*Connection, *wlturbo.Display, *net.UnixConn) { | |
| 269 | + | t.Helper() | |
| 270 | + | ca, server := inputPair(t) | |
| 271 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 272 | + | if err != nil { | |
| 273 | + | t.Fatal(err) | |
| 274 | + | } | |
| 275 | + | t.Cleanup(func() { _ = d.Close() }) | |
| 276 | + | inputRequest(t, server) | |
| 277 | + | c := &Connection{Display: d, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window)} | |
| 278 | + | c.seat = &Window{Display: d, connection: c, seatVersion: 9} | |
| 279 | + | c.seat.Seat = core.NewSeat(d.Context()) | |
| 280 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, c.seat.Seat); err != nil { | |
| 281 | + | t.Fatal(err) | |
| 282 | + | } | |
| 283 | + | inputRequest(t, server) | |
| 284 | + | return c, d, server | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func fdOpen(fd int) bool { | |
| 288 | + | _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) | |
| 289 | + | return err == nil | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestKeyboardGenerationKeymapClosedExactlyOnce(t *testing.T) { | |
| 293 | + | c, d, server := sharedSeat(t) | |
| 294 | + | for generation := range 3 { | |
| 295 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 2}); err != nil { | |
| 296 | + | t.Fatal(err) | |
| 297 | + | } | |
| 298 | + | inputRequest(t, server) // get_keyboard | |
| 299 | + | cache := c.seat.keymapCache | |
| 300 | + | if cache == nil || c.seat.WLKeyboard == nil { | |
| 301 | + | t.Fatalf("generation %d: no keyboard cache", generation) | |
| 302 | + | } | |
| 303 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |
| 304 | + | if err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | var taken []int | |
| 308 | + | for range 2 { // a second keymap in one generation replaces the first | |
| 309 | + | inputSend(t, server, inputFrame(c.seat.WLKeyboard.ID(), 0, 1, 4), int(f.Fd())) | |
| 310 | + | if err = d.Dispatch(); err != nil { | |
| 311 | + | t.Fatal(err) | |
| 312 | + | } | |
| 313 | + | cache.mu.Lock() | |
| 314 | + | taken = append(taken, cache.fd) | |
| 315 | + | cache.mu.Unlock() | |
| 316 | + | } | |
| 317 | + | f.Close() | |
| 318 | + | if taken[0] == taken[1] || fdOpen(taken[0]) { | |
| 319 | + | t.Fatalf("replaced keymap FD %d not closed", taken[0]) | |
| 320 | + | } | |
| 321 | + | if !fdOpen(taken[1]) { | |
| 322 | + | t.Fatalf("generation %d: live keymap FD closed", generation) | |
| 323 | + | } | |
| 324 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil { | |
| 325 | + | t.Fatal(err) | |
| 326 | + | } | |
| 327 | + | inputRequest(t, server) // release | |
| 328 | + | if fdOpen(taken[1]) { | |
| 329 | + | t.Fatalf("generation %d: keymap FD %d leaked after capability loss", generation, taken[1]) | |
| 330 | + | } | |
| 331 | + | if c.seat.keymapCache != nil || c.seat.WLKeyboard != nil { | |
| 332 | + | t.Fatal("keyboard generation not retired") | |
| 333 | + | } | |
| 334 | + | cache.close() // a retired cache closes nothing twice | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func TestPointerReplacementResetsSiblingCursor(t *testing.T) { | |
| 339 | + | c, d, server := sharedSeat(t) | |
| 340 | + | ctx := d.Context() | |
| 341 | + | shapes := cursorshape.NewWpCursorShapeManager(ctx) | |
| 342 | + | if err := d.Registry().Bind(2, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil { | |
| 343 | + | t.Fatal(err) | |
| 344 | + | } | |
| 345 | + | inputRequest(t, server) | |
| 346 | + | sibling := &Window{connection: c, CursorShapes: shapes, seatVersion: 9} | |
| 347 | + | c.windows[1] = sibling | |
| 348 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil { | |
| 349 | + | t.Fatal(err) | |
| 350 | + | } | |
| 351 | + | if id, op, _ := readRequest(t, server); id != c.seat.Seat.ID() || op != 0 { | |
| 352 | + | t.Fatalf("get_pointer %d %d", id, op) | |
| 353 | + | } | |
| 354 | + | first := sibling.Pointer | |
| 355 | + | if first == nil || sibling.pointerGen != 1 { | |
| 356 | + | t.Fatalf("sibling pointer/gen %v %d", first, sibling.pointerGen) | |
| 357 | + | } | |
| 358 | + | sibling.PointerEntered(7, 1) | |
| 359 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |
| 360 | + | t.Fatal(err) | |
| 361 | + | } | |
| 362 | + | _, _, body := readRequest(t, server) // shape device | |
| 363 | + | device := binary.LittleEndian.Uint32(body) | |
| 364 | + | readRequest(t, server) // set_shape | |
| 365 | + | // Pointer capability loss: device destroyed before the pointer, serial gone. | |
| 366 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil { | |
| 367 | + | t.Fatal(err) | |
| 368 | + | } | |
| 369 | + | if id, op, _ := readRequest(t, server); id != device || op != 0 { | |
| 370 | + | t.Fatalf("shape device not destroyed first: %d %d", id, op) | |
| 371 | + | } | |
| 372 | + | if id, _, _ := readRequest(t, server); id != first.ID() { | |
| 373 | + | t.Fatalf("pointer not released after device: %d", id) | |
| 374 | + | } | |
| 375 | + | if sibling.Pointer != nil || sibling.cursor.serial != 0 || sibling.cursor.device != nil { | |
| 376 | + | t.Fatal("sibling kept stale pointer state") | |
| 377 | + | } | |
| 378 | + | // Replacement: a late enter of the released generation is ignored. | |
| 379 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil { | |
| 380 | + | t.Fatal(err) | |
| 381 | + | } | |
| 382 | + | readRequest(t, server) // get_pointer | |
| 383 | + | if sibling.pointerGen != 2 || sibling.Pointer == nil || sibling.Pointer == first { | |
| 384 | + | t.Fatalf("replacement not adopted: gen %d", sibling.pointerGen) | |
| 385 | + | } | |
| 386 | + | sibling.PointerEntered(9, 1) | |
| 387 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |
| 388 | + | t.Fatal(err) | |
| 389 | + | } | |
| 390 | + | noRequest(t, server) | |
| 391 | + | sibling.PointerEntered(11, 2) | |
| 392 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |
| 393 | + | t.Fatal(err) | |
| 394 | + | } | |
| 395 | + | _, _, body = readRequest(t, server) | |
| 396 | + | if binary.LittleEndian.Uint32(body[4:]) != sibling.Pointer.ID() { | |
| 397 | + | t.Fatal("shape device bound to a stale pointer") | |
| 398 | + | } | |
| 399 | + | if _, _, body = readRequest(t, server); binary.LittleEndian.Uint32(body) != 11 { | |
| 400 | + | t.Fatalf("set_shape used stale serial: %x", body) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func TestOwnerSeatRemovalWithFullReaderQueue(t *testing.T) { | |
| 405 | + | c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |
| 406 | + | c.seat = &Window{connection: c} | |
| 407 | + | lock := &Window{LockSurface: nil, rawInput: []Event{{Kind: InputKey}}} | |
| 408 | + | c.windows[1], c.windows[2] = &Window{}, lock | |
| 409 | + | for range cap(c.events) { | |
| 410 | + | c.events <- Event{Kind: LockAcquired} | |
| 411 | + | } | |
| 412 | + | done := make(chan error, 1) | |
| 413 | + | go func() { done <- c.Apply(Event{Kind: SeatRemoved}) }() | |
| 414 | + | select { | |
| 415 | + | case err := <-done: | |
| 416 | + | if err != nil { | |
| 417 | + | t.Fatal(err) | |
| 418 | + | } | |
| 419 | + | case <-time.After(time.Second): | |
| 420 | + | close(c.stop) | |
| 421 | + | t.Fatal("seat removal blocked on the full reader queue") | |
| 422 | + | } | |
| 423 | + | if len(c.events) != cap(c.events) { | |
| 424 | + | t.Fatal("owner apply consumed or added reader events") | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | func TestReaderPauseNeverBlocksOnFullQueueAndResumes(t *testing.T) { | |
| 429 | + | ca, server := inputPair(t) | |
| 430 | + | d, err := wlturbo.ConnectFromConn(ca) | |
| 431 | + | if err != nil { | |
| 432 | + | t.Fatal(err) | |
| 433 | + | } | |
| 434 | + | inputRequest(t, server) | |
| 435 | + | c := &Connection{Display: d, socket: ca, events: make(chan Event, 1), stop: make(chan struct{}), windows: map[uint32]*Window{}, pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)} | |
| 436 | + | seat := core.NewSeat(d.Context()) | |
| 437 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |
| 438 | + | t.Fatal(err) | |
| 439 | + | } | |
| 440 | + | inputRequest(t, server) | |
| 441 | + | seat.OnCapabilities(func(caps uint32) { c.post(Event{Kind: SeatCapabilities, Capabilities: caps}) }) | |
| 442 | + | c.StartReader() | |
| 443 | + | defer func() { _ = c.Close() }() | |
| 444 | + | // Fill the one-slot queue, then a second event blocks the reader in post. | |
| 445 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 1)) | |
| 446 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 2)) | |
| 447 | + | time.Sleep(50 * time.Millisecond) | |
| 448 | + | paused := make(chan func(), 1) | |
| 449 | + | go func() { paused <- c.pauseReader() }() | |
| 450 | + | var resume func() | |
| 451 | + | select { | |
| 452 | + | case resume = <-paused: | |
| 453 | + | case <-time.After(2 * time.Second): | |
| 454 | + | t.Fatal("pause deadlocked against the full reader queue") | |
| 455 | + | } | |
| 456 | + | if first := <-c.events; first.Capabilities != 1 { | |
| 457 | + | t.Fatalf("first event %+v", first) | |
| 458 | + | } | |
| 459 | + | resume() | |
| 460 | + | select { | |
| 461 | + | case ev := <-c.events: | |
| 462 | + | if ev.Capabilities != 2 { | |
| 463 | + | t.Fatalf("parked event lost or reordered: %+v", ev) | |
| 464 | + | } | |
| 465 | + | case <-time.After(2 * time.Second): | |
| 466 | + | t.Fatal("parked event not re-posted after resume") | |
| 467 | + | } | |
| 468 | + | // A pause with an idle blocked read is interrupted too, and reading resumes. | |
| 469 | + | resume2 := c.pauseReader() | |
| 470 | + | resume2() | |
| 471 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 4)) | |
| 472 | + | select { | |
| 473 | + | case ev := <-c.events: | |
| 474 | + | if ev.Capabilities != 4 { | |
| 475 | + | t.Fatalf("event after resume %+v", ev) | |
| 476 | + | } | |
| 477 | + | case <-time.After(2 * time.Second): | |
| 478 | + | t.Fatal("reader did not resume") | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func TestReadyRequiresConfigureFeedbackAndFrame(t *testing.T) { | |
| 483 | + | w := &Window{Configured: true, FrameReady: true} | |
| 484 | + | if w.Ready() { | |
| 485 | + | t.Fatal("ready before dmabuf feedback completed") | |
| 486 | + | } | |
| 487 | + | w.FeedbackDone = true | |
| 488 | + | if !w.Ready() { | |
| 489 | + | t.Fatal("not ready") | |
| 490 | + | } | |
| 491 | + | // A later feedback round invalidates readiness until its own done. | |
| 492 | + | if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 2}); err != nil { | |
| 493 | + | t.Fatal(err) | |
| 494 | + | } | |
| 495 | + | if w.Ready() || len(w.Tranches) != 0 { | |
| 496 | + | t.Fatal("stale feedback kept across rounds") | |
| 497 | + | } | |
| 498 | + | if err := w.Present(nil, nil, nil, 0, 0); err == nil { | |
| 499 | + | t.Fatal("present without buffer accepted") | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func TestDrainStartupHoldsLockAndOutputEventsInOrder(t *testing.T) { | |
| 504 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}, outputs: map[uint32]Output{}} | |
| 505 | + | c.events <- Event{Kind: OutputAdded, Output: Output{Global: 3}} | |
| 506 | + | c.events <- Event{Kind: LockAcquired} | |
| 507 | + | c.events <- Event{Kind: OutputRemoved, Output: Output{Global: 3}} | |
| 508 | + | if err := c.drainStartup(true); err != nil { | |
| 509 | + | t.Fatal(err) | |
| 510 | + | } | |
| 511 | + | if c.Locked() || c.lockDone { | |
| 512 | + | t.Fatal("startup drain applied `locked`, hiding it from the owner") | |
| 513 | + | } | |
| 514 | + | held := c.TakeHeld() | |
| 515 | + | if len(held) != 3 || held[0].Kind != OutputAdded || held[1].Kind != LockAcquired || held[2].Kind != OutputRemoved { | |
| 516 | + | t.Fatalf("held %+v", held) | |
| 517 | + | } | |
| 518 | + | if len(c.TakeHeld()) != 0 { | |
| 519 | + | t.Fatal("held events delivered twice") | |
| 520 | + | } | |
| 521 | + | // The owner applies them itself; unlock then becomes possible. | |
| 522 | + | for _, ev := range held { | |
| 523 | + | if err := c.Apply(ev); err != nil { | |
| 524 | + | t.Fatal(err) | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | if !c.Locked() { | |
| 528 | + | t.Fatal("locked not recorded once the owner applied it") | |
| 529 | + | } | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func TestDrainStartupFinishedWrapsSentinelAndKeepsEvent(t *testing.T) { | |
| 533 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |
| 534 | + | c.events <- Event{Kind: LockFinished} | |
| 535 | + | err := c.drainStartup(true) | |
| 536 | + | if !errors.Is(err, ErrLockFinished) { | |
| 537 | + | t.Fatalf("err %v", err) | |
| 538 | + | } | |
| 539 | + | if held := c.TakeHeld(); len(held) != 1 || held[0].Kind != LockFinished { | |
| 540 | + | t.Fatalf("finished event lost: %+v", held) | |
| 541 | + | } | |
| 542 | + | // Window-tagged and ordinary events are still applied during setup. | |
| 543 | + | c.events <- Event{Kind: BufferRelease, BufferID: 1} | |
| 544 | + | if err := c.drainStartup(true); err == nil || errors.Is(err, ErrLockFinished) { | |
| 545 | + | t.Logf("non-lock event handled: %v", err) | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | // A closing session must never read the shared queue: it carries other | |
| 550 | + | // windows' releases, lock state, output changes and transport errors. | |
| 551 | + | func TestSharedWindowCloseLeavesReaderQueueAlone(t *testing.T) { | |
| 552 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |
| 553 | + | closing, other := &Window{connection: c, events: c.events}, &Window{connection: c, events: c.events} | |
| 554 | + | if closing.ReaderEvents() != nil { | |
| 555 | + | t.Fatal("shared window exposes the connection queue to its session") | |
| 556 | + | } | |
| 557 | + | if (&Window{events: make(chan Event)}).ReaderEvents() == nil { | |
| 558 | + | t.Fatal("standalone window lost its own queue") | |
| 559 | + | } | |
| 560 | + | queued := []Event{ | |
| 561 | + | {Kind: BufferRelease, BufferID: 1, Window: other}, | |
| 562 | + | {Kind: LockAcquired}, | |
| 563 | + | {Kind: OutputRemoved, Output: Output{Global: 9}}, | |
| 564 | + | {Kind: TransportError, Err: errors.New("gone")}, | |
| 565 | + | {Kind: InputKey, Code: 30, Window: other}, | |
| 566 | + | } | |
| 567 | + | for _, ev := range queued { | |
| 568 | + | c.events <- ev | |
| 569 | + | } | |
| 570 | + | if err := closing.Close(); err != nil { | |
| 571 | + | t.Fatal(err) | |
| 572 | + | } | |
| 573 | + | if len(c.events) != len(queued) { | |
| 574 | + | t.Fatalf("close consumed %d shared events", len(queued)-len(c.events)) | |
| 575 | + | } | |
| 576 | + | for i, want := range queued { | |
| 577 | + | if got := <-c.events; got.Kind != want.Kind || got.Window != want.Window { | |
| 578 | + | t.Fatalf("event %d reordered: %+v", i, got) | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | } | |
| @@ -0,0 +1,581 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "encoding/binary" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |||
| 11 | + | "golang.org/x/sys/unix" | |||
| 12 | + | "net" | |||
| 13 | + | "os" | |||
| 14 | + | "strings" | |||
| 15 | + | "syscall" | |||
| 16 | + | "testing" | |||
| 17 | + | "time" | |||
| 18 | + | ||||
| 19 | + | "github.com/bnema/wlturbo" | |||
| 20 | + | "github.com/bnema/wlturbo/protocol/core" | |||
| 21 | + | "github.com/bnema/wlturbo/protocol/cursorshape" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func TestSharedConnectionKeyboardRoutesOnlyFocusedWindow(t *testing.T) { | |||
| 25 | + | ca, server := inputPair(t) | |||
| 26 | + | d, err := wlturbo.ConnectFromConn(ca) | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | defer d.Close() | |||
| 31 | + | inputRequest(t, server) | |||
| 32 | + | c := &Connection{Display: d, events: make(chan Event, 32), stop: make(chan struct{}), windows: make(map[uint32]*Window)} | |||
| 33 | + | host := &Window{connection: c, seatVersion: 9} | |||
| 34 | + | seat := core.NewSeat(d.Context()) | |||
| 35 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | inputRequest(t, server) | |||
| 39 | + | keyboard, err := seat.GetKeyboard() | |||
| 40 | + | if err != nil { | |||
| 41 | + | t.Fatal(err) | |||
| 42 | + | } | |||
| 43 | + | inputRequest(t, server) | |||
| 44 | + | first, second := &Window{connection: c}, &Window{connection: c} | |||
| 45 | + | c.routes.Store(uint32(30), first) | |||
| 46 | + | c.routes.Store(uint32(31), second) | |||
| 47 | + | host.watchSharedKeyboard(keyboard) | |||
| 48 | + | // enter includes a length-prefixed empty key array, then key down. | |||
| 49 | + | for _, msg := range [][]byte{inputFrame(keyboard.ID(), 1, 1, 30, 0), inputFrame(keyboard.ID(), 3, 2, 0, 30, 1), inputFrame(keyboard.ID(), 2, 3, 30), inputFrame(keyboard.ID(), 1, 4, 31, 0), inputFrame(keyboard.ID(), 3, 5, 0, 48, 1)} { | |||
| 50 | + | inputSend(t, server, msg) | |||
| 51 | + | if err := d.Dispatch(); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | var keys []Event | |||
| 56 | + | for len(c.events) > 0 { | |||
| 57 | + | ev := <-c.events | |||
| 58 | + | if ev.Kind == InputKey { | |||
| 59 | + | keys = append(keys, ev) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if len(keys) != 2 || keys[0].Window != first || keys[0].Code != 30 || keys[1].Window != second || keys[1].Code != 48 { | |||
| 63 | + | t.Fatalf("keys: %+v", keys) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestHarnessConnectionLock(t *testing.T) { | |||
| 68 | + | out, _ := runLayerHarness(t, "TestHarnessConnectionLockClient") | |||
| 69 | + | for _, want := range []string{"LOCK acquired", "LOCK exact configure", "LOCK shared display", "LOCK sibling survived", "LOCK unlock", "LOCK manager reused", "LOCK named layer sibling"} { | |||
| 70 | + | if !strings.Contains(out, want) { | |||
| 71 | + | t.Fatalf("missing %q in %s", want, out) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | func TestHarnessConnectionLockClient(t *testing.T) { | |||
| 76 | + | harnessChild(t) | |||
| 77 | + | ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) | |||
| 78 | + | defer cancel() | |||
| 79 | + | c, err := ConnectConnection(ctx, "") | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | defer c.Close() | |||
| 84 | + | outputs := c.Outputs() | |||
| 85 | + | if len(outputs) != 1 { | |||
| 86 | + | t.Fatalf("outputs %+v", outputs) | |||
| 87 | + | } | |||
| 88 | + | if err := c.AcquireLock(); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | events := c.StartReader() | |||
| 92 | + | wait := func(want EventKind) { | |||
| 93 | + | t.Helper() | |||
| 94 | + | for { | |||
| 95 | + | select { | |||
| 96 | + | case ev := <-events: | |||
| 97 | + | if err := c.Apply(ev); err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | if ev.Kind == want { | |||
| 101 | + | return | |||
| 102 | + | } | |||
| 103 | + | case <-ctx.Done(): | |||
| 104 | + | t.Fatal("event timeout") | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | wait(LockAcquired) | |||
| 109 | + | fmt.Println("LOCK acquired") | |||
| 110 | + | first, err := c.NewWindow(ctx, 1, 1, false, SurfaceOptions{Lock: &LockOptions{Output: outputs[0].Global}}) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | for !first.Configured || !first.FeedbackDone || first.MainDevice == 0 || len(first.Tranches) == 0 { | |||
| 115 | + | select { | |||
| 116 | + | case ev := <-events: | |||
| 117 | + | if err := c.Apply(ev); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | case <-ctx.Done(): | |||
| 121 | + | t.Fatal("lock configure timeout") | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if first.Width != 400 || first.Height != 300 || first.LockSurface == nil || first.XdgSurface != nil || first.LayerSurface != nil { | |||
| 125 | + | t.Fatalf("lock geometry/roles: %dx%d", first.Width, first.Height) | |||
| 126 | + | } | |||
| 127 | + | fmt.Println("LOCK exact configure") | |||
| 128 | + | // An ordinary sibling shares the transport, but cannot acquire another lock. | |||
| 129 | + | second, err := c.NewWindow(ctx, 20, 20, false, SurfaceOptions{}) | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | if first.Display != second.Display || first.Display != c.Display || first.Seat != nil { | |||
| 134 | + | t.Fatal("independent display or lock exposed clipboard/IME seat") | |||
| 135 | + | } | |||
| 136 | + | fmt.Println("LOCK shared display") | |||
| 137 | + | if err := first.Close(); err != nil { | |||
| 138 | + | t.Fatal(err) | |||
| 139 | + | } | |||
| 140 | + | if second.Display == nil || c.closed { | |||
| 141 | + | t.Fatal("window close destroyed shared connection") | |||
| 142 | + | } | |||
| 143 | + | if err := second.SetTitle("sibling survives"); err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | fmt.Println("LOCK sibling survived") | |||
| 147 | + | if err := c.UnlockAndDestroy(); err != nil { | |||
| 148 | + | t.Fatal(err) | |||
| 149 | + | } | |||
| 150 | + | fmt.Println("LOCK unlock") | |||
| 151 | + | manager := c.lockManager | |||
| 152 | + | for range 2 { | |||
| 153 | + | if err := c.AcquireLock(); err != nil { | |||
| 154 | + | t.Fatal(err) | |||
| 155 | + | } | |||
| 156 | + | wait(LockAcquired) | |||
| 157 | + | if c.lockManager != manager { | |||
| 158 | + | t.Fatal("lock manager leaked across cycles") | |||
| 159 | + | } | |||
| 160 | + | if err := c.UnlockAndDestroy(); err != nil { | |||
| 161 | + | t.Fatal(err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | fmt.Println("LOCK manager reused") | |||
| 165 | + | layer, err := c.NewWindow(ctx, 20, 20, true, SurfaceOptions{Layer: &LayerOptions{Output: outputs[0].Name, Layer: LayerTop, Namespace: "named-sibling"}}) | |||
| 166 | + | if err != nil { | |||
| 167 | + | t.Fatal(err) | |||
| 168 | + | } | |||
| 169 | + | for !layer.Configured || !layer.FeedbackDone { | |||
| 170 | + | select { | |||
| 171 | + | case ev := <-events: | |||
| 172 | + | if err := c.Apply(ev); err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | case <-ctx.Done(): | |||
| 176 | + | t.Fatal("named sibling timeout") | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | if layer.layerOutputGlobal != outputs[0].Global { | |||
| 180 | + | t.Fatal("named sibling selected wrong output") | |||
| 181 | + | } | |||
| 182 | + | fmt.Println("LOCK named layer sibling") | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestOwnerFocusResetWithFullReaderQueue(t *testing.T) { | |||
| 186 | + | c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{1: {}}} | |||
| 187 | + | for range cap(c.events) { | |||
| 188 | + | c.events <- Event{Kind: LockAcquired} | |||
| 189 | + | } | |||
| 190 | + | done := make(chan error, 1) | |||
| 191 | + | go func() { done <- c.Apply(Event{Kind: InputFocusOut}) }() | |||
| 192 | + | select { | |||
| 193 | + | case err := <-done: | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatal(err) | |||
| 196 | + | } | |||
| 197 | + | case <-time.After(time.Second): | |||
| 198 | + | close(c.stop) | |||
| 199 | + | t.Fatal("owner reset self-posted into full reader queue") | |||
| 200 | + | } | |||
| 201 | + | if len(c.events) != cap(c.events) { | |||
| 202 | + | t.Fatal("owner reset consumed reader events") | |||
| 203 | + | } | |||
| 204 | + | inputs := c.windows[1].DrainInput(nil) | |||
| 205 | + | if len(inputs) != 1 || inputs[0].Kind != "focus-out" { | |||
| 206 | + | t.Fatalf("inputs %+v", inputs) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func TestKeyboardMapCacheRetiresEveryGeneration(t *testing.T) { | |||
| 211 | + | for range 4 { | |||
| 212 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |||
| 213 | + | if err != nil { | |||
| 214 | + | t.Fatal(err) | |||
| 215 | + | } | |||
| 216 | + | fd, err := syscall.Dup(int(f.Fd())) | |||
| 217 | + | if err != nil { | |||
| 218 | + | t.Fatal(err) | |||
| 219 | + | } | |||
| 220 | + | f.Close() | |||
| 221 | + | cache := &keyboardMapCache{fd: -1} | |||
| 222 | + | cache.replace(fd) | |||
| 223 | + | duplicate, err := cache.duplicate() | |||
| 224 | + | if err != nil || duplicate < 0 { | |||
| 225 | + | t.Fatalf("duplicate %d %v", duplicate, err) | |||
| 226 | + | } | |||
| 227 | + | if flags, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil || flags&unix.FD_CLOEXEC == 0 { | |||
| 228 | + | t.Fatalf("delivered keymap duplicate is inheritable: flags %d err %v", flags, err) | |||
| 229 | + | } | |||
| 230 | + | cache.close() | |||
| 231 | + | cache.close() | |||
| 232 | + | if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); !errors.Is(err, syscall.EBADF) { | |||
| 233 | + | t.Fatalf("retired map FD still open: %v", err) | |||
| 234 | + | } | |||
| 235 | + | if _, err := unix.FcntlInt(uintptr(duplicate), unix.F_GETFD, 0); err != nil { | |||
| 236 | + | t.Fatal("retired source closed delivered duplicate") | |||
| 237 | + | } | |||
| 238 | + | syscall.Close(duplicate) | |||
| 239 | + | if got, err := cache.duplicate(); got != -1 || err != nil { | |||
| 240 | + | t.Fatal("retired callback duplicated map") | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestFeedbackReadinessWaitsForCompleteEvent(t *testing.T) { | |||
| 246 | + | w := &Window{Configured: true} | |||
| 247 | + | if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 1}); err != nil { | |||
| 248 | + | t.Fatal(err) | |||
| 249 | + | } | |||
| 250 | + | if err := w.Apply(Event{Kind: FeedbackTranche, Tranche: []linuxdmabuf.FormatEntry{{}}}); err != nil { | |||
| 251 | + | t.Fatal(err) | |||
| 252 | + | } | |||
| 253 | + | if w.FeedbackDone { | |||
| 254 | + | t.Fatal("partial feedback treated as complete") | |||
| 255 | + | } | |||
| 256 | + | if err := w.Apply(Event{Kind: FeedbackFormats, Formats: []linuxdmabuf.FormatEntry{{}}}); err != nil { | |||
| 257 | + | t.Fatal(err) | |||
| 258 | + | } | |||
| 259 | + | if w.FeedbackDone { | |||
| 260 | + | t.Fatal("table alone treated as complete") | |||
| 261 | + | } | |||
| 262 | + | if err := w.Apply(Event{Kind: FeedbackComplete}); err != nil || !w.FeedbackDone { | |||
| 263 | + | t.Fatal("done not applied") | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // sharedSeat builds a Connection whose seat host is bound over a socketpair. | |||
| 268 | + | func sharedSeat(t *testing.T) (*Connection, *wlturbo.Display, *net.UnixConn) { | |||
| 269 | + | t.Helper() | |||
| 270 | + | ca, server := inputPair(t) | |||
| 271 | + | d, err := wlturbo.ConnectFromConn(ca) | |||
| 272 | + | if err != nil { | |||
| 273 | + | t.Fatal(err) | |||
| 274 | + | } | |||
| 275 | + | t.Cleanup(func() { _ = d.Close() }) | |||
| 276 | + | inputRequest(t, server) | |||
| 277 | + | c := &Connection{Display: d, events: make(chan Event, 256), stop: make(chan struct{}), windows: make(map[uint32]*Window)} | |||
| 278 | + | c.seat = &Window{Display: d, connection: c, seatVersion: 9} | |||
| 279 | + | c.seat.Seat = core.NewSeat(d.Context()) | |||
| 280 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, c.seat.Seat); err != nil { | |||
| 281 | + | t.Fatal(err) | |||
| 282 | + | } | |||
| 283 | + | inputRequest(t, server) | |||
| 284 | + | return c, d, server | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func fdOpen(fd int) bool { | |||
| 288 | + | _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) | |||
| 289 | + | return err == nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestKeyboardGenerationKeymapClosedExactlyOnce(t *testing.T) { | |||
| 293 | + | c, d, server := sharedSeat(t) | |||
| 294 | + | for generation := range 3 { | |||
| 295 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 2}); err != nil { | |||
| 296 | + | t.Fatal(err) | |||
| 297 | + | } | |||
| 298 | + | inputRequest(t, server) // get_keyboard | |||
| 299 | + | cache := c.seat.keymapCache | |||
| 300 | + | if cache == nil || c.seat.WLKeyboard == nil { | |||
| 301 | + | t.Fatalf("generation %d: no keyboard cache", generation) | |||
| 302 | + | } | |||
| 303 | + | f, err := os.CreateTemp(t.TempDir(), "keymap") | |||
| 304 | + | if err != nil { | |||
| 305 | + | t.Fatal(err) | |||
| 306 | + | } | |||
| 307 | + | var taken []int | |||
| 308 | + | for range 2 { // a second keymap in one generation replaces the first | |||
| 309 | + | inputSend(t, server, inputFrame(c.seat.WLKeyboard.ID(), 0, 1, 4), int(f.Fd())) | |||
| 310 | + | if err = d.Dispatch(); err != nil { | |||
| 311 | + | t.Fatal(err) | |||
| 312 | + | } | |||
| 313 | + | cache.mu.Lock() | |||
| 314 | + | taken = append(taken, cache.fd) | |||
| 315 | + | cache.mu.Unlock() | |||
| 316 | + | } | |||
| 317 | + | f.Close() | |||
| 318 | + | if taken[0] == taken[1] || fdOpen(taken[0]) { | |||
| 319 | + | t.Fatalf("replaced keymap FD %d not closed", taken[0]) | |||
| 320 | + | } | |||
| 321 | + | if !fdOpen(taken[1]) { | |||
| 322 | + | t.Fatalf("generation %d: live keymap FD closed", generation) | |||
| 323 | + | } | |||
| 324 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil { | |||
| 325 | + | t.Fatal(err) | |||
| 326 | + | } | |||
| 327 | + | inputRequest(t, server) // release | |||
| 328 | + | if fdOpen(taken[1]) { | |||
| 329 | + | t.Fatalf("generation %d: keymap FD %d leaked after capability loss", generation, taken[1]) | |||
| 330 | + | } | |||
| 331 | + | if c.seat.keymapCache != nil || c.seat.WLKeyboard != nil { | |||
| 332 | + | t.Fatal("keyboard generation not retired") | |||
| 333 | + | } | |||
| 334 | + | cache.close() // a retired cache closes nothing twice | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func TestPointerReplacementResetsSiblingCursor(t *testing.T) { | |||
| 339 | + | c, d, server := sharedSeat(t) | |||
| 340 | + | ctx := d.Context() | |||
| 341 | + | shapes := cursorshape.NewWpCursorShapeManager(ctx) | |||
| 342 | + | if err := d.Registry().Bind(2, cursorshape.WpCursorShapeManagerInterface, 1, shapes); err != nil { | |||
| 343 | + | t.Fatal(err) | |||
| 344 | + | } | |||
| 345 | + | inputRequest(t, server) | |||
| 346 | + | sibling := &Window{connection: c, CursorShapes: shapes, seatVersion: 9} | |||
| 347 | + | c.windows[1] = sibling | |||
| 348 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil { | |||
| 349 | + | t.Fatal(err) | |||
| 350 | + | } | |||
| 351 | + | if id, op, _ := readRequest(t, server); id != c.seat.Seat.ID() || op != 0 { | |||
| 352 | + | t.Fatalf("get_pointer %d %d", id, op) | |||
| 353 | + | } | |||
| 354 | + | first := sibling.Pointer | |||
| 355 | + | if first == nil || sibling.pointerGen != 1 { | |||
| 356 | + | t.Fatalf("sibling pointer/gen %v %d", first, sibling.pointerGen) | |||
| 357 | + | } | |||
| 358 | + | sibling.PointerEntered(7, 1) | |||
| 359 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |||
| 360 | + | t.Fatal(err) | |||
| 361 | + | } | |||
| 362 | + | _, _, body := readRequest(t, server) // shape device | |||
| 363 | + | device := binary.LittleEndian.Uint32(body) | |||
| 364 | + | readRequest(t, server) // set_shape | |||
| 365 | + | // Pointer capability loss: device destroyed before the pointer, serial gone. | |||
| 366 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 0}); err != nil { | |||
| 367 | + | t.Fatal(err) | |||
| 368 | + | } | |||
| 369 | + | if id, op, _ := readRequest(t, server); id != device || op != 0 { | |||
| 370 | + | t.Fatalf("shape device not destroyed first: %d %d", id, op) | |||
| 371 | + | } | |||
| 372 | + | if id, _, _ := readRequest(t, server); id != first.ID() { | |||
| 373 | + | t.Fatalf("pointer not released after device: %d", id) | |||
| 374 | + | } | |||
| 375 | + | if sibling.Pointer != nil || sibling.cursor.serial != 0 || sibling.cursor.device != nil { | |||
| 376 | + | t.Fatal("sibling kept stale pointer state") | |||
| 377 | + | } | |||
| 378 | + | // Replacement: a late enter of the released generation is ignored. | |||
| 379 | + | if err := c.Apply(Event{Kind: SeatCapabilities, Capabilities: 1}); err != nil { | |||
| 380 | + | t.Fatal(err) | |||
| 381 | + | } | |||
| 382 | + | readRequest(t, server) // get_pointer | |||
| 383 | + | if sibling.pointerGen != 2 || sibling.Pointer == nil || sibling.Pointer == first { | |||
| 384 | + | t.Fatalf("replacement not adopted: gen %d", sibling.pointerGen) | |||
| 385 | + | } | |||
| 386 | + | sibling.PointerEntered(9, 1) | |||
| 387 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |||
| 388 | + | t.Fatal(err) | |||
| 389 | + | } | |||
| 390 | + | noRequest(t, server) | |||
| 391 | + | sibling.PointerEntered(11, 2) | |||
| 392 | + | if err := sibling.SetCursor(CursorPointer); err != nil { | |||
| 393 | + | t.Fatal(err) | |||
| 394 | + | } | |||
| 395 | + | _, _, body = readRequest(t, server) | |||
| 396 | + | if binary.LittleEndian.Uint32(body[4:]) != sibling.Pointer.ID() { | |||
| 397 | + | t.Fatal("shape device bound to a stale pointer") | |||
| 398 | + | } | |||
| 399 | + | if _, _, body = readRequest(t, server); binary.LittleEndian.Uint32(body) != 11 { | |||
| 400 | + | t.Fatalf("set_shape used stale serial: %x", body) | |||
| 401 | + | } | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func TestOwnerSeatRemovalWithFullReaderQueue(t *testing.T) { | |||
| 405 | + | c := &Connection{events: make(chan Event, 256), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |||
| 406 | + | c.seat = &Window{connection: c} | |||
| 407 | + | lock := &Window{LockSurface: nil, rawInput: []Event{{Kind: InputKey}}} | |||
| 408 | + | c.windows[1], c.windows[2] = &Window{}, lock | |||
| 409 | + | for range cap(c.events) { | |||
| 410 | + | c.events <- Event{Kind: LockAcquired} | |||
| 411 | + | } | |||
| 412 | + | done := make(chan error, 1) | |||
| 413 | + | go func() { done <- c.Apply(Event{Kind: SeatRemoved}) }() | |||
| 414 | + | select { | |||
| 415 | + | case err := <-done: | |||
| 416 | + | if err != nil { | |||
| 417 | + | t.Fatal(err) | |||
| 418 | + | } | |||
| 419 | + | case <-time.After(time.Second): | |||
| 420 | + | close(c.stop) | |||
| 421 | + | t.Fatal("seat removal blocked on the full reader queue") | |||
| 422 | + | } | |||
| 423 | + | if len(c.events) != cap(c.events) { | |||
| 424 | + | t.Fatal("owner apply consumed or added reader events") | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | func TestReaderPauseNeverBlocksOnFullQueueAndResumes(t *testing.T) { | |||
| 429 | + | ca, server := inputPair(t) | |||
| 430 | + | d, err := wlturbo.ConnectFromConn(ca) | |||
| 431 | + | if err != nil { | |||
| 432 | + | t.Fatal(err) | |||
| 433 | + | } | |||
| 434 | + | inputRequest(t, server) | |||
| 435 | + | c := &Connection{Display: d, socket: ca, events: make(chan Event, 1), stop: make(chan struct{}), windows: map[uint32]*Window{}, pauseSig: make(chan struct{}), pauseAck: make(chan struct{}, 1), resume: make(chan struct{}, 1)} | |||
| 436 | + | seat := core.NewSeat(d.Context()) | |||
| 437 | + | if err := d.Registry().Bind(1, core.SeatInterface, 9, seat); err != nil { | |||
| 438 | + | t.Fatal(err) | |||
| 439 | + | } | |||
| 440 | + | inputRequest(t, server) | |||
| 441 | + | seat.OnCapabilities(func(caps uint32) { c.post(Event{Kind: SeatCapabilities, Capabilities: caps}) }) | |||
| 442 | + | c.StartReader() | |||
| 443 | + | defer func() { _ = c.Close() }() | |||
| 444 | + | // Fill the one-slot queue, then a second event blocks the reader in post. | |||
| 445 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 1)) | |||
| 446 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 2)) | |||
| 447 | + | time.Sleep(50 * time.Millisecond) | |||
| 448 | + | paused := make(chan func(), 1) | |||
| 449 | + | go func() { paused <- c.pauseReader() }() | |||
| 450 | + | var resume func() | |||
| 451 | + | select { | |||
| 452 | + | case resume = <-paused: | |||
| 453 | + | case <-time.After(2 * time.Second): | |||
| 454 | + | t.Fatal("pause deadlocked against the full reader queue") | |||
| 455 | + | } | |||
| 456 | + | if first := <-c.events; first.Capabilities != 1 { | |||
| 457 | + | t.Fatalf("first event %+v", first) | |||
| 458 | + | } | |||
| 459 | + | resume() | |||
| 460 | + | select { | |||
| 461 | + | case ev := <-c.events: | |||
| 462 | + | if ev.Capabilities != 2 { | |||
| 463 | + | t.Fatalf("parked event lost or reordered: %+v", ev) | |||
| 464 | + | } | |||
| 465 | + | case <-time.After(2 * time.Second): | |||
| 466 | + | t.Fatal("parked event not re-posted after resume") | |||
| 467 | + | } | |||
| 468 | + | // A pause with an idle blocked read is interrupted too, and reading resumes. | |||
| 469 | + | resume2 := c.pauseReader() | |||
| 470 | + | resume2() | |||
| 471 | + | inputSend(t, server, inputFrame(seat.ID(), 0, 4)) | |||
| 472 | + | select { | |||
| 473 | + | case ev := <-c.events: | |||
| 474 | + | if ev.Capabilities != 4 { | |||
| 475 | + | t.Fatalf("event after resume %+v", ev) | |||
| 476 | + | } | |||
| 477 | + | case <-time.After(2 * time.Second): | |||
| 478 | + | t.Fatal("reader did not resume") | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func TestReadyRequiresConfigureFeedbackAndFrame(t *testing.T) { | |||
| 483 | + | w := &Window{Configured: true, FrameReady: true} | |||
| 484 | + | if w.Ready() { | |||
| 485 | + | t.Fatal("ready before dmabuf feedback completed") | |||
| 486 | + | } | |||
| 487 | + | w.FeedbackDone = true | |||
| 488 | + | if !w.Ready() { | |||
| 489 | + | t.Fatal("not ready") | |||
| 490 | + | } | |||
| 491 | + | // A later feedback round invalidates readiness until its own done. | |||
| 492 | + | if err := w.Apply(Event{Kind: FeedbackMainDevice, Device: 2}); err != nil { | |||
| 493 | + | t.Fatal(err) | |||
| 494 | + | } | |||
| 495 | + | if w.Ready() || len(w.Tranches) != 0 { | |||
| 496 | + | t.Fatal("stale feedback kept across rounds") | |||
| 497 | + | } | |||
| 498 | + | if err := w.Present(nil, nil, nil, 0, 0); err == nil { | |||
| 499 | + | t.Fatal("present without buffer accepted") | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func TestDrainStartupHoldsLockAndOutputEventsInOrder(t *testing.T) { | |||
| 504 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}, outputs: map[uint32]Output{}} | |||
| 505 | + | c.events <- Event{Kind: OutputAdded, Output: Output{Global: 3}} | |||
| 506 | + | c.events <- Event{Kind: LockAcquired} | |||
| 507 | + | c.events <- Event{Kind: OutputRemoved, Output: Output{Global: 3}} | |||
| 508 | + | if err := c.drainStartup(true); err != nil { | |||
| 509 | + | t.Fatal(err) | |||
| 510 | + | } | |||
| 511 | + | if c.Locked() || c.lockDone { | |||
| 512 | + | t.Fatal("startup drain applied `locked`, hiding it from the owner") | |||
| 513 | + | } | |||
| 514 | + | held := c.TakeHeld() | |||
| 515 | + | if len(held) != 3 || held[0].Kind != OutputAdded || held[1].Kind != LockAcquired || held[2].Kind != OutputRemoved { | |||
| 516 | + | t.Fatalf("held %+v", held) | |||
| 517 | + | } | |||
| 518 | + | if len(c.TakeHeld()) != 0 { | |||
| 519 | + | t.Fatal("held events delivered twice") | |||
| 520 | + | } | |||
| 521 | + | // The owner applies them itself; unlock then becomes possible. | |||
| 522 | + | for _, ev := range held { | |||
| 523 | + | if err := c.Apply(ev); err != nil { | |||
| 524 | + | t.Fatal(err) | |||
| 525 | + | } | |||
| 526 | + | } | |||
| 527 | + | if !c.Locked() { | |||
| 528 | + | t.Fatal("locked not recorded once the owner applied it") | |||
| 529 | + | } | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func TestDrainStartupFinishedWrapsSentinelAndKeepsEvent(t *testing.T) { | |||
| 533 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |||
| 534 | + | c.events <- Event{Kind: LockFinished} | |||
| 535 | + | err := c.drainStartup(true) | |||
| 536 | + | if !errors.Is(err, ErrLockFinished) { | |||
| 537 | + | t.Fatalf("err %v", err) | |||
| 538 | + | } | |||
| 539 | + | if held := c.TakeHeld(); len(held) != 1 || held[0].Kind != LockFinished { | |||
| 540 | + | t.Fatalf("finished event lost: %+v", held) | |||
| 541 | + | } | |||
| 542 | + | // Window-tagged and ordinary events are still applied during setup. | |||
| 543 | + | c.events <- Event{Kind: BufferRelease, BufferID: 1} | |||
| 544 | + | if err := c.drainStartup(true); err == nil || errors.Is(err, ErrLockFinished) { | |||
| 545 | + | t.Logf("non-lock event handled: %v", err) | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | // A closing session must never read the shared queue: it carries other | |||
| 550 | + | // windows' releases, lock state, output changes and transport errors. | |||
| 551 | + | func TestSharedWindowCloseLeavesReaderQueueAlone(t *testing.T) { | |||
| 552 | + | c := &Connection{events: make(chan Event, 16), stop: make(chan struct{}), windows: map[uint32]*Window{}} | |||
| 553 | + | closing, other := &Window{connection: c, events: c.events}, &Window{connection: c, events: c.events} | |||
| 554 | + | if closing.ReaderEvents() != nil { | |||
| 555 | + | t.Fatal("shared window exposes the connection queue to its session") | |||
| 556 | + | } | |||
| 557 | + | if (&Window{events: make(chan Event)}).ReaderEvents() == nil { | |||
| 558 | + | t.Fatal("standalone window lost its own queue") | |||
| 559 | + | } | |||
| 560 | + | queued := []Event{ | |||
| 561 | + | {Kind: BufferRelease, BufferID: 1, Window: other}, | |||
| 562 | + | {Kind: LockAcquired}, | |||
| 563 | + | {Kind: OutputRemoved, Output: Output{Global: 9}}, | |||
| 564 | + | {Kind: TransportError, Err: errors.New("gone")}, | |||
| 565 | + | {Kind: InputKey, Code: 30, Window: other}, | |||
| 566 | + | } | |||
| 567 | + | for _, ev := range queued { | |||
| 568 | + | c.events <- ev | |||
| 569 | + | } | |||
| 570 | + | if err := closing.Close(); err != nil { | |||
| 571 | + | t.Fatal(err) | |||
| 572 | + | } | |||
| 573 | + | if len(c.events) != len(queued) { | |||
| 574 | + | t.Fatalf("close consumed %d shared events", len(queued)-len(c.events)) | |||
| 575 | + | } | |||
| 576 | + | for i, want := range queued { | |||
| 577 | + | if got := <-c.events; got.Kind != want.Kind || got.Window != want.Window { | |||
| 578 | + | t.Fatalf("event %d reordered: %+v", i, got) | |||
| 579 | + | } | |||
| 580 | + | } | |||
| 581 | + | } | |||
M internal/platform/wayland/events.go +73 -0
| @@ -33,14 +33,26 @@ const ( | |||
| 33 | 33 | FeedbackMainDevice | |
| 34 | 34 | FeedbackTranche | |
| 35 | 35 | FeedbackFormats | |
| 36 | + | FeedbackComplete | |
| 36 | 37 | ClipboardInput | |
| 37 | 38 | IMEInput | |
| 38 | 39 | // ConfigureLayer carries a layer-surface extent and its configure serial; | |
| 39 | 40 | // Apply resizes and acks together. Zero axes are unchanged. | |
| 40 | 41 | ConfigureLayer | |
| 42 | + | ConfigureLock | |
| 43 | + | OutputGlobal | |
| 44 | + | OutputNamed | |
| 45 | + | OutputScale | |
| 46 | + | OutputAdded | |
| 47 | + | OutputRemoved | |
| 48 | + | LockAcquired | |
| 49 | + | LockFinished | |
| 41 | 50 | ) | |
| 42 | 51 | ||
| 43 | 52 | type Event struct { | |
| 53 | + | Window *Window // tagged by transport, applied only by the shared UI owner | |
| 54 | + | Output Output | |
| 55 | + | Version uint32 | |
| 44 | 56 | Kind EventKind | |
| 45 | 57 | Width, Height int32 | |
| 46 | 58 | Serial, Scale uint32 | |
| @@ -62,6 +74,9 @@ type Event struct { | |||
| 62 | 74 | // StartReader launches exactly one blocking Wayland reader. Registered callbacks | |
| 63 | 75 | // only send typed values to Events; Apply is called by the session loop alone. | |
| 64 | 76 | func (w *Window) StartReader() <-chan Event { | |
| 77 | + | if w.connection != nil { | |
| 78 | + | return w.connection.StartReader() | |
| 79 | + | } | |
| 65 | 80 | w.readerOnce.Do(func() { | |
| 66 | 81 | w.readerStarted.Store(true) | |
| 67 | 82 | w.readerDone = make(chan struct{}) | |
| @@ -86,11 +101,28 @@ func (w *Window) StartReader() <-chan Event { | |||
| 86 | 101 | // Events exposes the reader's typed event queue to the session owner. | |
| 87 | 102 | func (w *Window) Events() <-chan Event { return w.events } | |
| 88 | 103 | ||
| 104 | + | // ReaderEvents is the queue a closing session may drain. It is nil for a window | |
| 105 | + | // on a Connection it does not own: that queue carries other windows' events, | |
| 106 | + | // the lock state and transport errors, and belongs to the connection owner. | |
| 107 | + | func (w *Window) ReaderEvents() <-chan Event { | |
| 108 | + | if w.connection != nil && !w.ownsConnection { | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | return w.events | |
| 112 | + | } | |
| 113 | + | ||
| 89 | 114 | // PostClipboard and PostIME enqueue transport callbacks for the owner loop. | |
| 90 | 115 | func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) } | |
| 91 | 116 | func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) } | |
| 92 | 117 | ||
| 93 | 118 | func (w *Window) post(ev Event) { | |
| 119 | + | if w.connection != nil { | |
| 120 | + | if w != w.connection.seat { | |
| 121 | + | ev.Window = w | |
| 122 | + | } | |
| 123 | + | w.connection.post(ev) | |
| 124 | + | return | |
| 125 | + | } | |
| 94 | 126 | select { | |
| 95 | 127 | case <-w.readerStop: | |
| 96 | 128 | return | |
| @@ -100,6 +132,10 @@ func (w *Window) post(ev Event) { | |||
| 100 | 132 | ||
| 101 | 133 | // postFD closes an undeliverable keymap descriptor rather than leaking it. | |
| 102 | 134 | func (w *Window) postFD(ev Event) { | |
| 135 | + | if w.connection != nil { | |
| 136 | + | w.post(ev) | |
| 137 | + | return | |
| 138 | + | } | |
| 103 | 139 | select { | |
| 104 | 140 | case <-w.readerStop: | |
| 105 | 141 | CloseEventFD(ev) | |
| @@ -113,6 +149,13 @@ func (w *Window) BufferReleased(id uint64) { | |||
| 113 | 149 | w.post(Event{Kind: BufferRelease, BufferID: id}) | |
| 114 | 150 | } | |
| 115 | 151 | func (w *Window) Apply(ev Event) error { | |
| 152 | + | if w.connection != nil && (ev.Window == nil || ev.Window != w) { | |
| 153 | + | return w.connection.Apply(ev) | |
| 154 | + | } | |
| 155 | + | if w.Closed { | |
| 156 | + | CloseEventFD(ev) | |
| 157 | + | return nil | |
| 158 | + | } | |
| 116 | 159 | switch ev.Kind { | |
| 117 | 160 | case ConfigureSize: | |
| 118 | 161 | if ev.Width > 0 && ev.Height > 0 && (w.Width != ev.Width || w.Height != ev.Height) { | |
| @@ -129,6 +172,15 @@ func (w *Window) Apply(ev Event) error { | |||
| 129 | 172 | return err | |
| 130 | 173 | } | |
| 131 | 174 | w.Configured, w.FrameReady = true, true | |
| 175 | + | case ConfigureLock: | |
| 176 | + | if w.LockSurface == nil || ev.Width <= 0 || ev.Height <= 0 || ev.Width > 16384 || ev.Height > 16384 { | |
| 177 | + | return fmt.Errorf("invalid lock configure %dx%d", ev.Width, ev.Height) | |
| 178 | + | } | |
| 179 | + | if err := w.LockSurface.AckConfigure(ev.Serial); err != nil { | |
| 180 | + | return err | |
| 181 | + | } | |
| 182 | + | w.Width, w.Height = ev.Width, ev.Height | |
| 183 | + | w.Configured, w.FrameReady, w.Dirty = true, true, true | |
| 132 | 184 | case ConfigureLayer: | |
| 133 | 185 | w.applyLayerSize(ev.Width, ev.Height) | |
| 134 | 186 | if w.LayerSurface == nil { | |
| @@ -164,16 +216,37 @@ func (w *Window) Apply(ev Event) error { | |||
| 164 | 216 | } | |
| 165 | 217 | return ev.Err | |
| 166 | 218 | case FeedbackMainDevice: | |
| 219 | + | if w.FeedbackDone { // a new feedback round replaces the previous one | |
| 220 | + | w.FeedbackDone, w.Tranches, w.Formats = false, nil, nil | |
| 221 | + | } | |
| 167 | 222 | w.MainDevice = ev.Device | |
| 168 | 223 | case FeedbackTranche: | |
| 169 | 224 | w.Tranches = append(w.Tranches, ev.Tranche) | |
| 170 | 225 | case FeedbackFormats: | |
| 171 | 226 | w.Formats = ev.Formats | |
| 227 | + | case FeedbackComplete: | |
| 228 | + | if w.feedbackErr != nil { | |
| 229 | + | return w.feedbackErr | |
| 230 | + | } | |
| 231 | + | w.FeedbackDone = true | |
| 172 | 232 | case SeatCapabilities, SeatRemoved: | |
| 173 | 233 | return w.ApplySeat(ev) | |
| 174 | 234 | case ClipboardInput, IMEInput: | |
| 175 | 235 | // These are applied by the session owner after Window.Apply. | |
| 176 | 236 | case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo: | |
| 237 | + | if w.LockSurface != nil { | |
| 238 | + | if ev.Kind == InputFrame { | |
| 239 | + | return nil // pointer input has no consumer on a lock surface | |
| 240 | + | } | |
| 241 | + | // Lock owners consume numeric/raw protocol events through their | |
| 242 | + | // secret keyboard path. Never instantiate the ordinary translator. | |
| 243 | + | if len(w.rawInput) >= 4096 { | |
| 244 | + | CloseEventFD(ev) | |
| 245 | + | return fmt.Errorf("lock raw input queue full") | |
| 246 | + | } | |
| 247 | + | w.rawInput = append(w.rawInput, ev) | |
| 248 | + | return nil | |
| 249 | + | } | |
| 177 | 250 | if w.inputAdapter == nil { | |
| 178 | 251 | var err error | |
| 179 | 252 | w.inputAdapter, err = NewInputAdapter("") | |
| @@ -33,14 +33,26 @@ const ( | |||||
| 33 | FeedbackMainDevice | 33 | FeedbackMainDevice | ||
| 34 | FeedbackTranche | 34 | FeedbackTranche | ||
| 35 | FeedbackFormats | 35 | FeedbackFormats | ||
| 36 | + | FeedbackComplete | |||
| 36 | ClipboardInput | 37 | ClipboardInput | ||
| 37 | IMEInput | 38 | IMEInput | ||
| 38 | // ConfigureLayer carries a layer-surface extent and its configure serial; | 39 | // ConfigureLayer carries a layer-surface extent and its configure serial; | ||
| 39 | // Apply resizes and acks together. Zero axes are unchanged. | 40 | // Apply resizes and acks together. Zero axes are unchanged. | ||
| 40 | ConfigureLayer | 41 | ConfigureLayer | ||
| 42 | + | ConfigureLock | |||
| 43 | + | OutputGlobal | |||
| 44 | + | OutputNamed | |||
| 45 | + | OutputScale | |||
| 46 | + | OutputAdded | |||
| 47 | + | OutputRemoved | |||
| 48 | + | LockAcquired | |||
| 49 | + | LockFinished | |||
| 41 | ) | 50 | ) | ||
| 42 | 51 | ||||
| 43 | type Event struct { | 52 | type Event struct { | ||
| 53 | + | Window *Window // tagged by transport, applied only by the shared UI owner | |||
| 54 | + | Output Output | |||
| 55 | + | Version uint32 | |||
| 44 | Kind EventKind | 56 | Kind EventKind | ||
| 45 | Width, Height int32 | 57 | Width, Height int32 | ||
| 46 | Serial, Scale uint32 | 58 | Serial, Scale uint32 | ||
| @@ -62,6 +74,9 @@ type Event struct { | |||||
| 62 | // StartReader launches exactly one blocking Wayland reader. Registered callbacks | 74 | // StartReader launches exactly one blocking Wayland reader. Registered callbacks | ||
| 63 | // only send typed values to Events; Apply is called by the session loop alone. | 75 | // only send typed values to Events; Apply is called by the session loop alone. | ||
| 64 | func (w *Window) StartReader() <-chan Event { | 76 | func (w *Window) StartReader() <-chan Event { | ||
| 77 | + | if w.connection != nil { | |||
| 78 | + | return w.connection.StartReader() | |||
| 79 | + | } | |||
| 65 | w.readerOnce.Do(func() { | 80 | w.readerOnce.Do(func() { | ||
| 66 | w.readerStarted.Store(true) | 81 | w.readerStarted.Store(true) | ||
| 67 | w.readerDone = make(chan struct{}) | 82 | w.readerDone = make(chan struct{}) | ||
| @@ -86,11 +101,28 @@ func (w *Window) StartReader() <-chan Event { | |||||
| 86 | // Events exposes the reader's typed event queue to the session owner. | 101 | // Events exposes the reader's typed event queue to the session owner. | ||
| 87 | func (w *Window) Events() <-chan Event { return w.events } | 102 | func (w *Window) Events() <-chan Event { return w.events } | ||
| 88 | 103 | ||||
| 104 | + | // ReaderEvents is the queue a closing session may drain. It is nil for a window | |||
| 105 | + | // on a Connection it does not own: that queue carries other windows' events, | |||
| 106 | + | // the lock state and transport errors, and belongs to the connection owner. | |||
| 107 | + | func (w *Window) ReaderEvents() <-chan Event { | |||
| 108 | + | if w.connection != nil && !w.ownsConnection { | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | return w.events | |||
| 112 | + | } | |||
| 113 | + | ||||
| 89 | // PostClipboard and PostIME enqueue transport callbacks for the owner loop. | 114 | // PostClipboard and PostIME enqueue transport callbacks for the owner loop. | ||
| 90 | func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) } | 115 | func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) } | ||
| 91 | func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) } | 116 | func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) } | ||
| 92 | 117 | ||||
| 93 | func (w *Window) post(ev Event) { | 118 | func (w *Window) post(ev Event) { | ||
| 119 | + | if w.connection != nil { | |||
| 120 | + | if w != w.connection.seat { | |||
| 121 | + | ev.Window = w | |||
| 122 | + | } | |||
| 123 | + | w.connection.post(ev) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 94 | select { | 126 | select { | ||
| 95 | case <-w.readerStop: | 127 | case <-w.readerStop: | ||
| 96 | return | 128 | return | ||
| @@ -100,6 +132,10 @@ func (w *Window) post(ev Event) { | |||||
| 100 | 132 | ||||
| 101 | // postFD closes an undeliverable keymap descriptor rather than leaking it. | 133 | // postFD closes an undeliverable keymap descriptor rather than leaking it. | ||
| 102 | func (w *Window) postFD(ev Event) { | 134 | func (w *Window) postFD(ev Event) { | ||
| 135 | + | if w.connection != nil { | |||
| 136 | + | w.post(ev) | |||
| 137 | + | return | |||
| 138 | + | } | |||
| 103 | select { | 139 | select { | ||
| 104 | case <-w.readerStop: | 140 | case <-w.readerStop: | ||
| 105 | CloseEventFD(ev) | 141 | CloseEventFD(ev) | ||
| @@ -113,6 +149,13 @@ func (w *Window) BufferReleased(id uint64) { | |||||
| 113 | w.post(Event{Kind: BufferRelease, BufferID: id}) | 149 | w.post(Event{Kind: BufferRelease, BufferID: id}) | ||
| 114 | } | 150 | } | ||
| 115 | func (w *Window) Apply(ev Event) error { | 151 | func (w *Window) Apply(ev Event) error { | ||
| 152 | + | if w.connection != nil && (ev.Window == nil || ev.Window != w) { | |||
| 153 | + | return w.connection.Apply(ev) | |||
| 154 | + | } | |||
| 155 | + | if w.Closed { | |||
| 156 | + | CloseEventFD(ev) | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 116 | switch ev.Kind { | 159 | switch ev.Kind { | ||
| 117 | case ConfigureSize: | 160 | case ConfigureSize: | ||
| 118 | if ev.Width > 0 && ev.Height > 0 && (w.Width != ev.Width || w.Height != ev.Height) { | 161 | if ev.Width > 0 && ev.Height > 0 && (w.Width != ev.Width || w.Height != ev.Height) { | ||
| @@ -129,6 +172,15 @@ func (w *Window) Apply(ev Event) error { | |||||
| 129 | return err | 172 | return err | ||
| 130 | } | 173 | } | ||
| 131 | w.Configured, w.FrameReady = true, true | 174 | w.Configured, w.FrameReady = true, true | ||
| 175 | + | case ConfigureLock: | |||
| 176 | + | if w.LockSurface == nil || ev.Width <= 0 || ev.Height <= 0 || ev.Width > 16384 || ev.Height > 16384 { | |||
| 177 | + | return fmt.Errorf("invalid lock configure %dx%d", ev.Width, ev.Height) | |||
| 178 | + | } | |||
| 179 | + | if err := w.LockSurface.AckConfigure(ev.Serial); err != nil { | |||
| 180 | + | return err | |||
| 181 | + | } | |||
| 182 | + | w.Width, w.Height = ev.Width, ev.Height | |||
| 183 | + | w.Configured, w.FrameReady, w.Dirty = true, true, true | |||
| 132 | case ConfigureLayer: | 184 | case ConfigureLayer: | ||
| 133 | w.applyLayerSize(ev.Width, ev.Height) | 185 | w.applyLayerSize(ev.Width, ev.Height) | ||
| 134 | if w.LayerSurface == nil { | 186 | if w.LayerSurface == nil { | ||
| @@ -164,16 +216,37 @@ func (w *Window) Apply(ev Event) error { | |||||
| 164 | } | 216 | } | ||
| 165 | return ev.Err | 217 | return ev.Err | ||
| 166 | case FeedbackMainDevice: | 218 | case FeedbackMainDevice: | ||
| 219 | + | if w.FeedbackDone { // a new feedback round replaces the previous one | |||
| 220 | + | w.FeedbackDone, w.Tranches, w.Formats = false, nil, nil | |||
| 221 | + | } | |||
| 167 | w.MainDevice = ev.Device | 222 | w.MainDevice = ev.Device | ||
| 168 | case FeedbackTranche: | 223 | case FeedbackTranche: | ||
| 169 | w.Tranches = append(w.Tranches, ev.Tranche) | 224 | w.Tranches = append(w.Tranches, ev.Tranche) | ||
| 170 | case FeedbackFormats: | 225 | case FeedbackFormats: | ||
| 171 | w.Formats = ev.Formats | 226 | w.Formats = ev.Formats | ||
| 227 | + | case FeedbackComplete: | |||
| 228 | + | if w.feedbackErr != nil { | |||
| 229 | + | return w.feedbackErr | |||
| 230 | + | } | |||
| 231 | + | w.FeedbackDone = true | |||
| 172 | case SeatCapabilities, SeatRemoved: | 232 | case SeatCapabilities, SeatRemoved: | ||
| 173 | return w.ApplySeat(ev) | 233 | return w.ApplySeat(ev) | ||
| 174 | case ClipboardInput, IMEInput: | 234 | case ClipboardInput, IMEInput: | ||
| 175 | // These are applied by the session owner after Window.Apply. | 235 | // These are applied by the session owner after Window.Apply. | ||
| 176 | case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo: | 236 | case InputFrame, InputKeymap, InputFocusIn, InputFocusOut, InputKey, InputModifiers, InputRepeatInfo: | ||
| 237 | + | if w.LockSurface != nil { | |||
| 238 | + | if ev.Kind == InputFrame { | |||
| 239 | + | return nil // pointer input has no consumer on a lock surface | |||
| 240 | + | } | |||
| 241 | + | // Lock owners consume numeric/raw protocol events through their | |||
| 242 | + | // secret keyboard path. Never instantiate the ordinary translator. | |||
| 243 | + | if len(w.rawInput) >= 4096 { | |||
| 244 | + | CloseEventFD(ev) | |||
| 245 | + | return fmt.Errorf("lock raw input queue full") | |||
| 246 | + | } | |||
| 247 | + | w.rawInput = append(w.rawInput, ev) | |||
| 248 | + | return nil | |||
| 249 | + | } | |||
| 177 | if w.inputAdapter == nil { | 250 | if w.inputAdapter == nil { | ||
| 178 | var err error | 251 | var err error | ||
| 179 | w.inputAdapter, err = NewInputAdapter("") | 252 | w.inputAdapter, err = NewInputAdapter("") | ||
M internal/platform/wayland/input.go +48 -3
| @@ -150,8 +150,8 @@ func (r seatRemoved) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) | |||
| 150 | 150 | func (w *Window) ApplySeat(ev Event) error { | |
| 151 | 151 | switch ev.Kind { | |
| 152 | 152 | case SeatRemoved: | |
| 153 | - | if w.WLKeyboard != nil { | |
| 154 | - | w.post(Event{Kind: InputFocusOut}) | |
| 153 | + | if w.WLKeyboard != nil && w.connection == nil { | |
| 154 | + | w.ownerFocusOut() | |
| 155 | 155 | } | |
| 156 | 156 | return w.releaseSeat() | |
| 157 | 157 | case SeatCapabilities: | |
| @@ -166,7 +166,13 @@ func (w *Window) ApplySeat(ev Event) error { | |||
| 166 | 166 | if ev.Capabilities&2 == 0 && w.WLKeyboard != nil { | |
| 167 | 167 | w.releaseDevice(w.WLKeyboard) | |
| 168 | 168 | w.WLKeyboard = nil | |
| 169 | - | w.post(Event{Kind: InputFocusOut}) | |
| 169 | + | if w.keymapCache != nil { | |
| 170 | + | w.keymapCache.close() | |
| 171 | + | w.keymapCache = nil | |
| 172 | + | } | |
| 173 | + | if w.connection == nil { | |
| 174 | + | w.ownerFocusOut() | |
| 175 | + | } | |
| 170 | 176 | } | |
| 171 | 177 | if ev.Capabilities&1 != 0 && w.Pointer == nil { | |
| 172 | 178 | p, err := w.Seat.GetPointer() | |
| @@ -201,7 +207,38 @@ func (w *Window) releaseDevice(p interface { | |||
| 201 | 207 | c.Unregister(p) | |
| 202 | 208 | } | |
| 203 | 209 | } | |
| 210 | + | ||
| 211 | + | // TakeInputEvents transfers lock-only raw input ownership, including keymap | |
| 212 | + | // FDs. The secret owner must close each FD after consuming it. No Text/Key | |
| 213 | + | // translation occurs here; ordinary windows continue to use Inputs(). | |
| 214 | + | func (w *Window) TakeInputEvents() []Event { | |
| 215 | + | events := w.rawInput | |
| 216 | + | w.rawInput = nil | |
| 217 | + | return events | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (w *Window) ownerFocusOut() { | |
| 221 | + | if w.LockSurface != nil { | |
| 222 | + | for _, ev := range w.rawInput { | |
| 223 | + | CloseEventFD(ev) | |
| 224 | + | } | |
| 225 | + | clear(w.rawInput) | |
| 226 | + | w.rawInput = append(w.rawInput[:0], Event{Kind: InputFocusOut, Window: w}) | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | if w.inputAdapter != nil { | |
| 230 | + | w.inputAdapter.Keyboard.Focus(false) | |
| 231 | + | w.inputAdapter.focused = false | |
| 232 | + | w.inputAdapter.serial = 0 | |
| 233 | + | } | |
| 234 | + | w.queueInput([]Input{{Kind: "focus-out"}}) | |
| 235 | + | } | |
| 236 | + | ||
| 204 | 237 | func (w *Window) releaseSeat() error { | |
| 238 | + | if w.keymapCache != nil { | |
| 239 | + | w.keymapCache.close() | |
| 240 | + | w.keymapCache = nil | |
| 241 | + | } | |
| 205 | 242 | if w.Pointer != nil { | |
| 206 | 243 | w.dropCursorDevice() | |
| 207 | 244 | w.releaseDevice(w.Pointer) | |
| @@ -222,6 +259,10 @@ func (w *Window) releaseSeat() error { | |||
| 222 | 259 | return nil | |
| 223 | 260 | } | |
| 224 | 261 | func (w *Window) watchPointer(p *core.Pointer) { | |
| 262 | + | if w.connection != nil { | |
| 263 | + | w.watchSharedPointer(p) | |
| 264 | + | return | |
| 265 | + | } | |
| 225 | 266 | var frame []Input // only accessed by the reader | |
| 226 | 267 | var x, y float64 | |
| 227 | 268 | var inside bool | |
| @@ -300,6 +341,10 @@ func (w *Window) watchPointer(p *core.Pointer) { | |||
| 300 | 341 | p.OnFrame(flush) | |
| 301 | 342 | } | |
| 302 | 343 | func (w *Window) watchKeyboard(k *core.Keyboard) { | |
| 344 | + | if w.connection != nil { | |
| 345 | + | w.watchSharedKeyboard(k) | |
| 346 | + | return | |
| 347 | + | } | |
| 303 | 348 | surfaceID := w.Surface.ID() | |
| 304 | 349 | k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) { | |
| 305 | 350 | n, err := fd.Take() | |
| @@ -150,8 +150,8 @@ func (r seatRemoved) HandleRegistryGlobalRemove(e wl.RegistryGlobalRemoveEvent) | |||||
| 150 | func (w *Window) ApplySeat(ev Event) error { | 150 | func (w *Window) ApplySeat(ev Event) error { | ||
| 151 | switch ev.Kind { | 151 | switch ev.Kind { | ||
| 152 | case SeatRemoved: | 152 | case SeatRemoved: | ||
| 153 | - | if w.WLKeyboard != nil { | 153 | + | if w.WLKeyboard != nil && w.connection == nil { |
| 154 | - | w.post(Event{Kind: InputFocusOut}) | 154 | + | w.ownerFocusOut() |
| 155 | } | 155 | } | ||
| 156 | return w.releaseSeat() | 156 | return w.releaseSeat() | ||
| 157 | case SeatCapabilities: | 157 | case SeatCapabilities: | ||
| @@ -166,7 +166,13 @@ func (w *Window) ApplySeat(ev Event) error { | |||||
| 166 | if ev.Capabilities&2 == 0 && w.WLKeyboard != nil { | 166 | if ev.Capabilities&2 == 0 && w.WLKeyboard != nil { | ||
| 167 | w.releaseDevice(w.WLKeyboard) | 167 | w.releaseDevice(w.WLKeyboard) | ||
| 168 | w.WLKeyboard = nil | 168 | w.WLKeyboard = nil | ||
| 169 | - | w.post(Event{Kind: InputFocusOut}) | 169 | + | if w.keymapCache != nil { |
| 170 | + | w.keymapCache.close() | |||
| 171 | + | w.keymapCache = nil | |||
| 172 | + | } | |||
| 173 | + | if w.connection == nil { | |||
| 174 | + | w.ownerFocusOut() | |||
| 175 | + | } | |||
| 170 | } | 176 | } | ||
| 171 | if ev.Capabilities&1 != 0 && w.Pointer == nil { | 177 | if ev.Capabilities&1 != 0 && w.Pointer == nil { | ||
| 172 | p, err := w.Seat.GetPointer() | 178 | p, err := w.Seat.GetPointer() | ||
| @@ -201,7 +207,38 @@ func (w *Window) releaseDevice(p interface { | |||||
| 201 | c.Unregister(p) | 207 | c.Unregister(p) | ||
| 202 | } | 208 | } | ||
| 203 | } | 209 | } | ||
| 210 | + | ||||
| 211 | + | // TakeInputEvents transfers lock-only raw input ownership, including keymap | |||
| 212 | + | // FDs. The secret owner must close each FD after consuming it. No Text/Key | |||
| 213 | + | // translation occurs here; ordinary windows continue to use Inputs(). | |||
| 214 | + | func (w *Window) TakeInputEvents() []Event { | |||
| 215 | + | events := w.rawInput | |||
| 216 | + | w.rawInput = nil | |||
| 217 | + | return events | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (w *Window) ownerFocusOut() { | |||
| 221 | + | if w.LockSurface != nil { | |||
| 222 | + | for _, ev := range w.rawInput { | |||
| 223 | + | CloseEventFD(ev) | |||
| 224 | + | } | |||
| 225 | + | clear(w.rawInput) | |||
| 226 | + | w.rawInput = append(w.rawInput[:0], Event{Kind: InputFocusOut, Window: w}) | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | if w.inputAdapter != nil { | |||
| 230 | + | w.inputAdapter.Keyboard.Focus(false) | |||
| 231 | + | w.inputAdapter.focused = false | |||
| 232 | + | w.inputAdapter.serial = 0 | |||
| 233 | + | } | |||
| 234 | + | w.queueInput([]Input{{Kind: "focus-out"}}) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 204 | func (w *Window) releaseSeat() error { | 237 | func (w *Window) releaseSeat() error { | ||
| 238 | + | if w.keymapCache != nil { | |||
| 239 | + | w.keymapCache.close() | |||
| 240 | + | w.keymapCache = nil | |||
| 241 | + | } | |||
| 205 | if w.Pointer != nil { | 242 | if w.Pointer != nil { | ||
| 206 | w.dropCursorDevice() | 243 | w.dropCursorDevice() | ||
| 207 | w.releaseDevice(w.Pointer) | 244 | w.releaseDevice(w.Pointer) | ||
| @@ -222,6 +259,10 @@ func (w *Window) releaseSeat() error { | |||||
| 222 | return nil | 259 | return nil | ||
| 223 | } | 260 | } | ||
| 224 | func (w *Window) watchPointer(p *core.Pointer) { | 261 | func (w *Window) watchPointer(p *core.Pointer) { | ||
| 262 | + | if w.connection != nil { | |||
| 263 | + | w.watchSharedPointer(p) | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 225 | var frame []Input // only accessed by the reader | 266 | var frame []Input // only accessed by the reader | ||
| 226 | var x, y float64 | 267 | var x, y float64 | ||
| 227 | var inside bool | 268 | var inside bool | ||
| @@ -300,6 +341,10 @@ func (w *Window) watchPointer(p *core.Pointer) { | |||||
| 300 | p.OnFrame(flush) | 341 | p.OnFrame(flush) | ||
| 301 | } | 342 | } | ||
| 302 | func (w *Window) watchKeyboard(k *core.Keyboard) { | 343 | func (w *Window) watchKeyboard(k *core.Keyboard) { | ||
| 344 | + | if w.connection != nil { | |||
| 345 | + | w.watchSharedKeyboard(k) | |||
| 346 | + | return | |||
| 347 | + | } | |||
| 303 | surfaceID := w.Surface.ID() | 348 | surfaceID := w.Surface.ID() | ||
| 304 | k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) { | 349 | k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) { | ||
| 305 | n, err := fd.Take() | 350 | n, err := fd.Take() | ||
M internal/platform/wayland/layer.go +17 -2
| @@ -82,6 +82,9 @@ type LayerOptions struct { | |||
| 82 | 82 | type SurfaceOptions struct { | |
| 83 | 83 | // Layer, when non-nil, creates a layer surface and binds no xdg shell. | |
| 84 | 84 | Layer *LayerOptions | |
| 85 | + | // Lock selects a private output lifetime on this Connection's sole lock. | |
| 86 | + | // It is incompatible with Layer, transparency and custom input regions. | |
| 87 | + | Lock *LockOptions | |
| 85 | 88 | // InputRects is the initial input region in surface-local logical pixels. | |
| 86 | 89 | // nil means the whole surface (the protocol default); a non-nil empty slice | |
| 87 | 90 | // means no input (pointer and touch pass through). | |
| @@ -90,6 +93,9 @@ type SurfaceOptions struct { | |||
| 90 | 93 | ||
| 91 | 94 | // Validate reports the first invalid option. | |
| 92 | 95 | func (o SurfaceOptions) Validate() error { | |
| 96 | + | if o.Lock != nil && (o.Layer != nil || o.InputRects != nil) { | |
| 97 | + | return errors.New("lock role cannot have layer or custom input") | |
| 98 | + | } | |
| 93 | 99 | if o.Layer != nil { | |
| 94 | 100 | if err := o.Layer.Validate(); err != nil { | |
| 95 | 101 | return err | |
| @@ -246,6 +252,14 @@ func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) { | |||
| 246 | 252 | // selectOutput binds every wl_output that can report a name and returns the | |
| 247 | 253 | // global name of the exact match. Unmatched outputs are released. | |
| 248 | 254 | func (w *Window) selectOutput(name string) (*core.Output, uint32, error) { | |
| 255 | + | if w.connection != nil { | |
| 256 | + | for _, out := range w.connection.outputs { | |
| 257 | + | if out.Name == name { | |
| 258 | + | return out.proxy, out.Global, nil | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output %q not found in applied inventory", name)} | |
| 262 | + | } | |
| 249 | 263 | reg := w.Display.Registry() | |
| 250 | 264 | var globals []wl.Global | |
| 251 | 265 | legacy := 0 // outputs older than v4 cannot report a name | |
| @@ -317,8 +331,9 @@ func (w *Window) createLayerSurface(o LayerOptions) error { | |||
| 317 | 331 | ns = DefaultLayerNamespace | |
| 318 | 332 | } | |
| 319 | 333 | ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns) | |
| 320 | - | if out != nil { | |
| 321 | - | // The compositor keeps its own reference to the output. | |
| 334 | + | if out != nil && w.connection == nil { | |
| 335 | + | // The compositor keeps its own reference to the output. A shared | |
| 336 | + | // connection owns the proxy in its output inventory instead. | |
| 322 | 337 | _ = out.Release() | |
| 323 | 338 | } | |
| 324 | 339 | if err != nil { | |
| @@ -82,6 +82,9 @@ type LayerOptions struct { | |||||
| 82 | type SurfaceOptions struct { | 82 | type SurfaceOptions struct { | ||
| 83 | // Layer, when non-nil, creates a layer surface and binds no xdg shell. | 83 | // Layer, when non-nil, creates a layer surface and binds no xdg shell. | ||
| 84 | Layer *LayerOptions | 84 | Layer *LayerOptions | ||
| 85 | + | // Lock selects a private output lifetime on this Connection's sole lock. | |||
| 86 | + | // It is incompatible with Layer, transparency and custom input regions. | |||
| 87 | + | Lock *LockOptions | |||
| 85 | // InputRects is the initial input region in surface-local logical pixels. | 88 | // InputRects is the initial input region in surface-local logical pixels. | ||
| 86 | // nil means the whole surface (the protocol default); a non-nil empty slice | 89 | // nil means the whole surface (the protocol default); a non-nil empty slice | ||
| 87 | // means no input (pointer and touch pass through). | 90 | // means no input (pointer and touch pass through). | ||
| @@ -90,6 +93,9 @@ type SurfaceOptions struct { | |||||
| 90 | 93 | ||||
| 91 | // Validate reports the first invalid option. | 94 | // Validate reports the first invalid option. | ||
| 92 | func (o SurfaceOptions) Validate() error { | 95 | func (o SurfaceOptions) Validate() error { | ||
| 96 | + | if o.Lock != nil && (o.Layer != nil || o.InputRects != nil) { | |||
| 97 | + | return errors.New("lock role cannot have layer or custom input") | |||
| 98 | + | } | |||
| 93 | if o.Layer != nil { | 99 | if o.Layer != nil { | ||
| 94 | if err := o.Layer.Validate(); err != nil { | 100 | if err := o.Layer.Validate(); err != nil { | ||
| 95 | return err | 101 | return err | ||
| @@ -246,6 +252,14 @@ func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) { | |||||
| 246 | // selectOutput binds every wl_output that can report a name and returns the | 252 | // selectOutput binds every wl_output that can report a name and returns the | ||
| 247 | // global name of the exact match. Unmatched outputs are released. | 253 | // global name of the exact match. Unmatched outputs are released. | ||
| 248 | func (w *Window) selectOutput(name string) (*core.Output, uint32, error) { | 254 | func (w *Window) selectOutput(name string) (*core.Output, uint32, error) { | ||
| 255 | + | if w.connection != nil { | |||
| 256 | + | for _, out := range w.connection.outputs { | |||
| 257 | + | if out.Name == name { | |||
| 258 | + | return out.proxy, out.Global, nil | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | return nil, 0, &CapabilityError{Name: core.OutputInterface, Cause: fmt.Errorf("output %q not found in applied inventory", name)} | |||
| 262 | + | } | |||
| 249 | reg := w.Display.Registry() | 263 | reg := w.Display.Registry() | ||
| 250 | var globals []wl.Global | 264 | var globals []wl.Global | ||
| 251 | legacy := 0 // outputs older than v4 cannot report a name | 265 | legacy := 0 // outputs older than v4 cannot report a name | ||
| @@ -317,8 +331,9 @@ func (w *Window) createLayerSurface(o LayerOptions) error { | |||||
| 317 | ns = DefaultLayerNamespace | 331 | ns = DefaultLayerNamespace | ||
| 318 | } | 332 | } | ||
| 319 | ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns) | 333 | ls, err := w.LayerShell.GetLayerSurface(w.Surface, out, o.Layer, ns) | ||
| 320 | - | if out != nil { | 334 | + | if out != nil && w.connection == nil { |
| 321 | - | // The compositor keeps its own reference to the output. | 335 | + | // The compositor keeps its own reference to the output. A shared |
| 336 | + | // connection owns the proxy in its output inventory instead. | |||
| 322 | _ = out.Release() | 337 | _ = out.Release() | ||
| 323 | } | 338 | } | ||
| 324 | if err != nil { | 339 | if err != nil { | ||
A internal/platform/wayland/lock.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package wayland | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // LockOptions selects one registry output lifetime returned by Outputs. | |
| 8 | + | // The lock object is deliberately private to Connection, never passed by UI. | |
| 9 | + | type LockOptions struct{ Output uint32 } | |
| 10 | + | ||
| 11 | + | func (w *Window) createLockSurface(opts LockOptions) error { | |
| 12 | + | c := w.connection | |
| 13 | + | if c == nil || c.lock == nil { | |
| 14 | + | return fmt.Errorf("lock window requires acquired connection lock") | |
| 15 | + | } | |
| 16 | + | out, ok := c.outputs[opts.Output] | |
| 17 | + | if !ok || out.proxy == nil { | |
| 18 | + | return fmt.Errorf("lock output %d unavailable", opts.Output) | |
| 19 | + | } | |
| 20 | + | role, err := c.lock.GetLockSurface(w.Surface, out.proxy) | |
| 21 | + | if err != nil { | |
| 22 | + | return err | |
| 23 | + | } | |
| 24 | + | w.LockSurface, w.lockOutput = role, opts.Output | |
| 25 | + | role.OnConfigure(func(serial, width, height uint32) { | |
| 26 | + | w.post(Event{Kind: ConfigureLock, Serial: serial, Width: int32(width), Height: int32(height)}) | |
| 27 | + | }) | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // LockOutput is the registry lifetime this lock surface covers. | |
| 32 | + | func (w *Window) LockOutput() uint32 { return w.lockOutput } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package wayland | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // LockOptions selects one registry output lifetime returned by Outputs. | |||
| 8 | + | // The lock object is deliberately private to Connection, never passed by UI. | |||
| 9 | + | type LockOptions struct{ Output uint32 } | |||
| 10 | + | ||||
| 11 | + | func (w *Window) createLockSurface(opts LockOptions) error { | |||
| 12 | + | c := w.connection | |||
| 13 | + | if c == nil || c.lock == nil { | |||
| 14 | + | return fmt.Errorf("lock window requires acquired connection lock") | |||
| 15 | + | } | |||
| 16 | + | out, ok := c.outputs[opts.Output] | |||
| 17 | + | if !ok || out.proxy == nil { | |||
| 18 | + | return fmt.Errorf("lock output %d unavailable", opts.Output) | |||
| 19 | + | } | |||
| 20 | + | role, err := c.lock.GetLockSurface(w.Surface, out.proxy) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return err | |||
| 23 | + | } | |||
| 24 | + | w.LockSurface, w.lockOutput = role, opts.Output | |||
| 25 | + | role.OnConfigure(func(serial, width, height uint32) { | |||
| 26 | + | w.post(Event{Kind: ConfigureLock, Serial: serial, Width: int32(width), Height: int32(height)}) | |||
| 27 | + | }) | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // LockOutput is the registry lifetime this lock surface covers. | |||
| 32 | + | func (w *Window) LockOutput() uint32 { return w.lockOutput } | |||
M internal/platform/wayland/sequence_test.go +1 -1
| @@ -60,7 +60,7 @@ func TestPresentWireSequence(t *testing.T) { | |||
| 60 | 60 | p.SetID(uint32(20 + i)) | |
| 61 | 61 | ctx.Register(p) | |
| 62 | 62 | } | |
| 63 | - | w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FrameReady: true, Transparent: true} | |
| 63 | + | w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FeedbackDone: true, FrameReady: true, Transparent: true} | |
| 64 | 64 | p, _ := buffers.New(1) | |
| 65 | 65 | // The fake server receives a finite number of requests; no dispatch loop/GPU. | |
| 66 | 66 | for frame := 0; frame < 2; frame++ { | |
| @@ -60,7 +60,7 @@ func TestPresentWireSequence(t *testing.T) { | |||||
| 60 | p.SetID(uint32(20 + i)) | 60 | p.SetID(uint32(20 + i)) | ||
| 61 | ctx.Register(p) | 61 | ctx.Register(p) | ||
| 62 | } | 62 | } | ||
| 63 | - | w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FrameReady: true, Transparent: true} | 63 | + | w := &Window{Surface: surface, SyncSurface: syncSurface, Width: 40, Height: 30, Scale: 1, Configured: true, FeedbackDone: true, FrameReady: true, Transparent: true} |
| 64 | p, _ := buffers.New(1) | 64 | p, _ := buffers.New(1) | ||
| 65 | // The fake server receives a finite number of requests; no dispatch loop/GPU. | 65 | // The fake server receives a finite number of requests; no dispatch loop/GPU. | ||
| 66 | for frame := 0; frame < 2; frame++ { | 66 | for frame := 0; frame < 2; frame++ { | ||
M internal/platform/wayland/window.go +122 -25
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "errors" | |
| 12 | 12 | "fmt" | |
| 13 | 13 | "math" | |
| 14 | - | "net" | |
| 15 | 14 | "os" | |
| 16 | 15 | "path/filepath" | |
| 17 | 16 | "sync" | |
| @@ -22,6 +21,7 @@ import ( | |||
| 22 | 21 | "github.com/bnema/wlturbo/protocol/core" | |
| 23 | 22 | "github.com/bnema/wlturbo/protocol/cursorshape" | |
| 24 | 23 | "github.com/bnema/wlturbo/protocol/drmsyncobj" | |
| 24 | + | "github.com/bnema/wlturbo/protocol/extsessionlock" | |
| 25 | 25 | "github.com/bnema/wlturbo/protocol/fractionalscale" | |
| 26 | 26 | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | |
| 27 | 27 | "github.com/bnema/wlturbo/protocol/viewporter" | |
| @@ -48,6 +48,9 @@ const ( | |||
| 48 | 48 | ) | |
| 49 | 49 | ||
| 50 | 50 | type Window struct { | |
| 51 | + | connection *Connection | |
| 52 | + | ownsConnection bool | |
| 53 | + | LockSurface *extsessionlock.ExtSessionLockSurface | |
| 51 | 54 | Display *wl.Display | |
| 52 | 55 | Compositor *core.Compositor | |
| 53 | 56 | Shell *xdgshell.XdgWmBase | |
| @@ -70,6 +73,7 @@ type Window struct { | |||
| 70 | 73 | cursor cursorState | |
| 71 | 74 | pointerGen uint64 // bumped per wl_pointer; session loop only | |
| 72 | 75 | WLKeyboard *core.Keyboard | |
| 76 | + | keymapCache *keyboardMapCache // owner retires each keyboard generation | |
| 73 | 77 | seatName, seatVersion uint32 | |
| 74 | 78 | Width, Height int32 | |
| 75 | 79 | Scale float64 | |
| @@ -77,6 +81,7 @@ type Window struct { | |||
| 77 | 81 | Transparent bool | |
| 78 | 82 | FrameReady bool | |
| 79 | 83 | // Feedback fields are populated by the default feedback event stream. | |
| 84 | + | FeedbackDone bool // owner has applied the complete feedback round | |
| 80 | 85 | MainDevice uint64 | |
| 81 | 86 | Formats []linuxdmabuf.FormatEntry | |
| 82 | 87 | Tranches [][]linuxdmabuf.FormatEntry | |
| @@ -86,12 +91,14 @@ type Window struct { | |||
| 86 | 91 | readerStop chan struct{} | |
| 87 | 92 | readerOnce sync.Once | |
| 88 | 93 | readerStarted atomic.Bool | |
| 94 | + | rawInput []Event // lock-only numeric protocol events, never ordinary text | |
| 89 | 95 | inputAdapter *InputAdapter | |
| 90 | 96 | inputQueue []InputEvent | |
| 91 | 97 | // inputRects/inputCustom record the current input region; session loop only. | |
| 92 | 98 | inputRects []Rect | |
| 93 | 99 | inputCustom bool | |
| 94 | 100 | layerOutputGlobal uint32 | |
| 101 | + | lockOutput uint32 | |
| 95 | 102 | layerOutputSelected bool | |
| 96 | 103 | // InputOverflow counts events discarded when the 4096-event queue fills. | |
| 97 | 104 | InputOverflow uint64 | |
| @@ -129,35 +136,66 @@ func waylandSocketPath(name string) (string, error) { | |||
| 129 | 136 | // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the | |
| 130 | 137 | // connection is closed and the returned error wraps ctx.Err(). Once it | |
| 131 | 138 | // returns successfully, ctx no longer affects the window. | |
| 132 | - | func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) { | |
| 139 | + | func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (*Window, error) { | |
| 133 | 140 | if ctx == nil { | |
| 134 | 141 | return nil, errors.New("nil context") | |
| 135 | 142 | } | |
| 136 | 143 | if width <= 0 || height <= 0 { | |
| 137 | 144 | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) | |
| 138 | 145 | } | |
| 139 | - | if err = opts.Validate(); err != nil { | |
| 146 | + | if err := opts.Validate(); err != nil { | |
| 140 | 147 | return nil, err | |
| 141 | 148 | } | |
| 142 | - | if err = ctx.Err(); err != nil { | |
| 149 | + | c, err := ConnectConnection(ctx, name) | |
| 150 | + | if err != nil { | |
| 143 | 151 | return nil, err | |
| 144 | 152 | } | |
| 145 | - | path, err := waylandSocketPath(name) | |
| 153 | + | w, err := c.NewWindow(ctx, width, height, transparent, opts) | |
| 146 | 154 | if err != nil { | |
| 155 | + | _ = c.Close() | |
| 147 | 156 | return nil, err | |
| 148 | 157 | } | |
| 149 | - | dialer := net.Dialer{Timeout: dialTimeout} | |
| 150 | - | conn, err := dialer.DialContext(ctx, "unix", path) | |
| 151 | - | if err != nil { | |
| 152 | - | return nil, fmt.Errorf("failed to connect to Wayland: %w", err) | |
| 158 | + | w.ownsConnection = true | |
| 159 | + | return w, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // NewWindow creates a sibling on this connection. Before StartReader it waits | |
| 163 | + | // for initial configure/feedback; afterwards it returns unconfigured and the | |
| 164 | + | // owner must Apply tagged events before allocating/presenting buffers. | |
| 165 | + | func (c *Connection) NewWindow(ctx context.Context, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) { | |
| 166 | + | if ctx == nil { | |
| 167 | + | return nil, errors.New("nil context") | |
| 153 | 168 | } | |
| 154 | - | d, err := wlturbo.ConnectFromConn(conn) | |
| 155 | - | if err != nil { | |
| 169 | + | if width <= 0 || height <= 0 { | |
| 170 | + | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) | |
| 171 | + | } | |
| 172 | + | if err = opts.Validate(); err != nil { | |
| 173 | + | return nil, err | |
| 174 | + | } | |
| 175 | + | if err = ctx.Err(); err != nil { | |
| 156 | 176 | return nil, err | |
| 157 | 177 | } | |
| 178 | + | if c.closed { | |
| 179 | + | return nil, errors.New("connection closed") | |
| 180 | + | } | |
| 181 | + | if opts.Lock != nil && (transparent || opts.Layer != nil || opts.InputRects != nil) { | |
| 182 | + | return nil, errors.New("lock role requires opaque full-input surface") | |
| 183 | + | } | |
| 184 | + | if opts.Lock != nil && !c.started.Load() { | |
| 185 | + | // A lock that was already refused or ended must not get surfaces; its | |
| 186 | + | // lock and output events are held for the owner, not applied here. | |
| 187 | + | if err = c.drainStartup(true); err != nil { | |
| 188 | + | return nil, err | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | // Objects created below get their handlers after their request is sent; | |
| 192 | + | // a running reader must not dispatch their first events in between. | |
| 193 | + | defer c.pauseReader()() | |
| 194 | + | d := c.Display | |
| 158 | 195 | // Closing the display unblocks any pending read; Display.Close is idempotent. | |
| 159 | 196 | stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() }) | |
| 160 | - | w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} | |
| 197 | + | w := &Window{connection: c, events: c.events, readerStop: c.stop, Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} | |
| 198 | + | w.readerStarted.Store(c.started.Load()) | |
| 161 | 199 | defer func() { | |
| 162 | 200 | if !stopWatch() && err == nil { | |
| 163 | 201 | // Cancelled after the last step but before return: the watcher owns a | |
| @@ -172,9 +210,6 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||
| 172 | 210 | res = nil | |
| 173 | 211 | } | |
| 174 | 212 | }() | |
| 175 | - | if err = d.Roundtrip(); err != nil { | |
| 176 | - | return nil, err | |
| 177 | - | } | |
| 178 | 213 | wctx := d.Context() | |
| 179 | 214 | bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error { | |
| 180 | 215 | advertised, ok := d.Registry().FindGlobal(name) | |
| @@ -198,7 +233,7 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||
| 198 | 233 | if err = w.bindLayerShell(bind); err != nil { | |
| 199 | 234 | return nil, err | |
| 200 | 235 | } | |
| 201 | - | } else { | |
| 236 | + | } else if opts.Lock == nil { | |
| 202 | 237 | w.Shell = xdgshell.NewXdgWmBase(wctx) | |
| 203 | 238 | if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil { | |
| 204 | 239 | return nil, err | |
| @@ -236,9 +271,11 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||
| 236 | 271 | wctx.Unregister(w.CursorShapes) | |
| 237 | 272 | w.CursorShapes = nil | |
| 238 | 273 | } | |
| 239 | - | if err = w.BindSeat(); err != nil { | |
| 240 | - | return nil, err | |
| 241 | - | } | |
| 274 | + | w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard | |
| 275 | + | if opts.Lock != nil { | |
| 276 | + | w.Seat = nil | |
| 277 | + | } // clipboard/IME constructors require Seat | |
| 278 | + | w.pointerGen = c.seat.pointerGen | |
| 242 | 279 | if w.Shell != nil { | |
| 243 | 280 | w.Shell.OnPing(func(serial uint32) { | |
| 244 | 281 | if e := w.Shell.Pong(serial); e != nil { | |
| @@ -290,7 +327,13 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||
| 290 | 327 | w.Dirty = true | |
| 291 | 328 | } | |
| 292 | 329 | }) | |
| 293 | - | if opts.Layer != nil { | |
| 330 | + | c.windows[w.Surface.ID()] = w | |
| 331 | + | c.routes.Store(w.Surface.ID(), w) | |
| 332 | + | if opts.Lock != nil { | |
| 333 | + | if err = w.createLockSurface(*opts.Lock); err != nil { | |
| 334 | + | return nil, err | |
| 335 | + | } | |
| 336 | + | } else if opts.Layer != nil { | |
| 294 | 337 | if err = w.createLayerSurface(*opts.Layer); err != nil { | |
| 295 | 338 | return nil, err | |
| 296 | 339 | } | |
| @@ -311,16 +354,29 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||
| 311 | 354 | return nil, err | |
| 312 | 355 | } | |
| 313 | 356 | w.watchFeedback() | |
| 314 | - | if err = w.Surface.Commit(); err != nil { | |
| 315 | - | return nil, err | |
| 316 | - | } // no buffer before configure | |
| 317 | - | for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 { | |
| 357 | + | // ext-session-lock configure is sent by get_lock_surface; unlike xdg and | |
| 358 | + | // layer-shell it forbids the initial empty commit before ACK+buffer. | |
| 359 | + | if opts.Lock == nil { | |
| 360 | + | if err = w.Surface.Commit(); err != nil { | |
| 361 | + | return nil, err | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | if c.started.Load() { | |
| 365 | + | return w, nil | |
| 366 | + | } | |
| 367 | + | for !w.Configured || !w.FeedbackDone || w.MainDevice == 0 || len(w.Tranches) == 0 { | |
| 318 | 368 | if w.Closed { | |
| 319 | 369 | return nil, fmt.Errorf("window closed before initial configure and feedback") | |
| 320 | 370 | } | |
| 371 | + | if err = c.drainStartup(true); err != nil { | |
| 372 | + | return nil, err // a finished already queued: do not wait for a configure | |
| 373 | + | } | |
| 321 | 374 | if err = w.Dispatch(); err != nil { | |
| 322 | 375 | return nil, err | |
| 323 | 376 | } | |
| 377 | + | if err = c.drainStartup(true); err != nil { | |
| 378 | + | return nil, err | |
| 379 | + | } | |
| 324 | 380 | } | |
| 325 | 381 | return w, nil | |
| 326 | 382 | } | |
| @@ -366,6 +422,9 @@ func (w *Window) createToplevel() error { | |||
| 366 | 422 | } | |
| 367 | 423 | ||
| 368 | 424 | func (w *Window) Dispatch() error { | |
| 425 | + | if w.connection != nil && w.connection.started.Load() { | |
| 426 | + | return errors.New("connection reader already started") | |
| 427 | + | } | |
| 369 | 428 | if w.feedbackErr != nil { | |
| 370 | 429 | return w.feedbackErr | |
| 371 | 430 | } | |
| @@ -375,6 +434,14 @@ func (w *Window) Dispatch() error { | |||
| 375 | 434 | return w.feedbackErr | |
| 376 | 435 | } | |
| 377 | 436 | ||
| 437 | + | // Ready reports that a buffer may be allocated and attached: the role's | |
| 438 | + | // configure was acknowledged, the dmabuf feedback round is complete and the | |
| 439 | + | // previous frame callback has fired. | |
| 440 | + | func (w *Window) Ready() bool { return w.Configured && w.FeedbackDone && w.FrameReady } | |
| 441 | + | ||
| 442 | + | // IsLock reports whether the window is an ext-session-lock surface. | |
| 443 | + | func (w *Window) IsLock() bool { return w != nil && w.LockSurface != nil } | |
| 444 | + | ||
| 378 | 445 | func (w *Window) PhysicalSize() (int32, int32, error) { | |
| 379 | 446 | if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 { | |
| 380 | 447 | return 0, 0, fmt.Errorf("invalid window geometry") | |
| @@ -396,6 +463,9 @@ func (w *Window) Present(buffer *core.Buffer, acquire, release *drmsyncobj.WpLin | |||
| 396 | 463 | if !w.Configured || buffer == nil || acquire == nil || release == nil { | |
| 397 | 464 | return fmt.Errorf("unconfigured surface or missing buffer/timeline") | |
| 398 | 465 | } | |
| 466 | + | if !w.FeedbackDone { | |
| 467 | + | return fmt.Errorf("dmabuf feedback incomplete") | |
| 468 | + | } | |
| 399 | 469 | if !w.FrameReady { | |
| 400 | 470 | return fmt.Errorf("frame callback pending") | |
| 401 | 471 | } | |
| @@ -478,15 +548,24 @@ func (w *Window) Close() error { | |||
| 478 | 548 | if w == nil { | |
| 479 | 549 | return nil | |
| 480 | 550 | } | |
| 551 | + | if w.ownsConnection && w.connection != nil && !w.connection.closed { | |
| 552 | + | return w.connection.Close() | |
| 553 | + | } | |
| 554 | + | for _, ev := range w.rawInput { | |
| 555 | + | CloseEventFD(ev) | |
| 556 | + | } | |
| 557 | + | clear(w.rawInput) | |
| 558 | + | w.rawInput = nil | |
| 481 | 559 | // The loop sends protocol destructors before Close; the reader is | |
| 482 | 560 | // unblocked by the connection close at the end of this method. | |
| 483 | 561 | if w.inputAdapter != nil { | |
| 484 | 562 | w.inputAdapter.Close() | |
| 485 | 563 | w.inputAdapter = nil | |
| 486 | 564 | } | |
| 487 | - | if w.Seat != nil { | |
| 565 | + | if w.Seat != nil && w.connection == nil { | |
| 488 | 566 | _ = w.releaseSeat() | |
| 489 | 567 | } | |
| 568 | + | w.dropCursorDevice() | |
| 490 | 569 | if w.CursorShapes != nil { | |
| 491 | 570 | _ = w.CursorShapes.Destroy() | |
| 492 | 571 | w.CursorShapes = nil | |
| @@ -499,6 +578,14 @@ func (w *Window) Close() error { | |||
| 499 | 578 | _ = w.SyncSurface.Destroy() | |
| 500 | 579 | w.SyncSurface = nil | |
| 501 | 580 | } | |
| 581 | + | if w.connection != nil && w.Surface != nil { | |
| 582 | + | delete(w.connection.windows, w.Surface.ID()) | |
| 583 | + | w.connection.routes.Delete(w.Surface.ID()) | |
| 584 | + | } | |
| 585 | + | if w.LockSurface != nil { | |
| 586 | + | _ = w.LockSurface.Destroy() | |
| 587 | + | w.LockSurface = nil | |
| 588 | + | } | |
| 502 | 589 | w.closeLayer() // before wl_surface | |
| 503 | 590 | if w.Toplevel != nil { | |
| 504 | 591 | _ = w.Toplevel.Destroy() | |
| @@ -548,6 +635,14 @@ func (w *Window) Close() error { | |||
| 548 | 635 | } | |
| 549 | 636 | w.Compositor = nil | |
| 550 | 637 | } | |
| 638 | + | if w.connection != nil { | |
| 639 | + | w.Display = nil | |
| 640 | + | w.Closed = true | |
| 641 | + | if w.ownsConnection { | |
| 642 | + | return w.connection.Close() | |
| 643 | + | } | |
| 644 | + | return nil | |
| 645 | + | } | |
| 551 | 646 | if w.Display != nil { | |
| 552 | 647 | if w.readerDone != nil { | |
| 553 | 648 | close(w.readerStop) | |
| @@ -609,8 +704,10 @@ func (w *Window) watchFeedback() { | |||
| 609 | 704 | w.Feedback.OnDone(func() { | |
| 610 | 705 | if w.readerStarted.Load() { | |
| 611 | 706 | w.post(Event{Kind: FeedbackFormats, Formats: table}) | |
| 707 | + | w.post(Event{Kind: FeedbackComplete}) | |
| 612 | 708 | } else { | |
| 613 | 709 | w.Formats = table | |
| 710 | + | w.FeedbackDone = true | |
| 614 | 711 | } | |
| 615 | 712 | }) | |
| 616 | 713 | } | |
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | "errors" | 11 | "errors" | ||
| 12 | "fmt" | 12 | "fmt" | ||
| 13 | "math" | 13 | "math" | ||
| 14 | - | "net" | |||
| 15 | "os" | 14 | "os" | ||
| 16 | "path/filepath" | 15 | "path/filepath" | ||
| 17 | "sync" | 16 | "sync" | ||
| @@ -22,6 +21,7 @@ import ( | |||||
| 22 | "github.com/bnema/wlturbo/protocol/core" | 21 | "github.com/bnema/wlturbo/protocol/core" | ||
| 23 | "github.com/bnema/wlturbo/protocol/cursorshape" | 22 | "github.com/bnema/wlturbo/protocol/cursorshape" | ||
| 24 | "github.com/bnema/wlturbo/protocol/drmsyncobj" | 23 | "github.com/bnema/wlturbo/protocol/drmsyncobj" | ||
| 24 | + | "github.com/bnema/wlturbo/protocol/extsessionlock" | |||
| 25 | "github.com/bnema/wlturbo/protocol/fractionalscale" | 25 | "github.com/bnema/wlturbo/protocol/fractionalscale" | ||
| 26 | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | 26 | "github.com/bnema/wlturbo/protocol/linuxdmabuf" | ||
| 27 | "github.com/bnema/wlturbo/protocol/viewporter" | 27 | "github.com/bnema/wlturbo/protocol/viewporter" | ||
| @@ -48,6 +48,9 @@ const ( | |||||
| 48 | ) | 48 | ) | ||
| 49 | 49 | ||||
| 50 | type Window struct { | 50 | type Window struct { | ||
| 51 | + | connection *Connection | |||
| 52 | + | ownsConnection bool | |||
| 53 | + | LockSurface *extsessionlock.ExtSessionLockSurface | |||
| 51 | Display *wl.Display | 54 | Display *wl.Display | ||
| 52 | Compositor *core.Compositor | 55 | Compositor *core.Compositor | ||
| 53 | Shell *xdgshell.XdgWmBase | 56 | Shell *xdgshell.XdgWmBase | ||
| @@ -70,6 +73,7 @@ type Window struct { | |||||
| 70 | cursor cursorState | 73 | cursor cursorState | ||
| 71 | pointerGen uint64 // bumped per wl_pointer; session loop only | 74 | pointerGen uint64 // bumped per wl_pointer; session loop only | ||
| 72 | WLKeyboard *core.Keyboard | 75 | WLKeyboard *core.Keyboard | ||
| 76 | + | keymapCache *keyboardMapCache // owner retires each keyboard generation | |||
| 73 | seatName, seatVersion uint32 | 77 | seatName, seatVersion uint32 | ||
| 74 | Width, Height int32 | 78 | Width, Height int32 | ||
| 75 | Scale float64 | 79 | Scale float64 | ||
| @@ -77,6 +81,7 @@ type Window struct { | |||||
| 77 | Transparent bool | 81 | Transparent bool | ||
| 78 | FrameReady bool | 82 | FrameReady bool | ||
| 79 | // Feedback fields are populated by the default feedback event stream. | 83 | // Feedback fields are populated by the default feedback event stream. | ||
| 84 | + | FeedbackDone bool // owner has applied the complete feedback round | |||
| 80 | MainDevice uint64 | 85 | MainDevice uint64 | ||
| 81 | Formats []linuxdmabuf.FormatEntry | 86 | Formats []linuxdmabuf.FormatEntry | ||
| 82 | Tranches [][]linuxdmabuf.FormatEntry | 87 | Tranches [][]linuxdmabuf.FormatEntry | ||
| @@ -86,12 +91,14 @@ type Window struct { | |||||
| 86 | readerStop chan struct{} | 91 | readerStop chan struct{} | ||
| 87 | readerOnce sync.Once | 92 | readerOnce sync.Once | ||
| 88 | readerStarted atomic.Bool | 93 | readerStarted atomic.Bool | ||
| 94 | + | rawInput []Event // lock-only numeric protocol events, never ordinary text | |||
| 89 | inputAdapter *InputAdapter | 95 | inputAdapter *InputAdapter | ||
| 90 | inputQueue []InputEvent | 96 | inputQueue []InputEvent | ||
| 91 | // inputRects/inputCustom record the current input region; session loop only. | 97 | // inputRects/inputCustom record the current input region; session loop only. | ||
| 92 | inputRects []Rect | 98 | inputRects []Rect | ||
| 93 | inputCustom bool | 99 | inputCustom bool | ||
| 94 | layerOutputGlobal uint32 | 100 | layerOutputGlobal uint32 | ||
| 101 | + | lockOutput uint32 | |||
| 95 | layerOutputSelected bool | 102 | layerOutputSelected bool | ||
| 96 | // InputOverflow counts events discarded when the 4096-event queue fills. | 103 | // InputOverflow counts events discarded when the 4096-event queue fills. | ||
| 97 | InputOverflow uint64 | 104 | InputOverflow uint64 | ||
| @@ -129,35 +136,66 @@ func waylandSocketPath(name string) (string, error) { | |||||
| 129 | // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the | 136 | // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the | ||
| 130 | // connection is closed and the returned error wraps ctx.Err(). Once it | 137 | // connection is closed and the returned error wraps ctx.Err(). Once it | ||
| 131 | // returns successfully, ctx no longer affects the window. | 138 | // returns successfully, ctx no longer affects the window. | ||
| 132 | - | func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) { | 139 | + | func ConnectWithOptions(ctx context.Context, name string, width, height int32, transparent bool, opts SurfaceOptions) (*Window, error) { |
| 133 | if ctx == nil { | 140 | if ctx == nil { | ||
| 134 | return nil, errors.New("nil context") | 141 | return nil, errors.New("nil context") | ||
| 135 | } | 142 | } | ||
| 136 | if width <= 0 || height <= 0 { | 143 | if width <= 0 || height <= 0 { | ||
| 137 | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) | 144 | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) | ||
| 138 | } | 145 | } | ||
| 139 | - | if err = opts.Validate(); err != nil { | 146 | + | if err := opts.Validate(); err != nil { |
| 140 | return nil, err | 147 | return nil, err | ||
| 141 | } | 148 | } | ||
| 142 | - | if err = ctx.Err(); err != nil { | 149 | + | c, err := ConnectConnection(ctx, name) |
| 150 | + | if err != nil { | |||
| 143 | return nil, err | 151 | return nil, err | ||
| 144 | } | 152 | } | ||
| 145 | - | path, err := waylandSocketPath(name) | 153 | + | w, err := c.NewWindow(ctx, width, height, transparent, opts) |
| 146 | if err != nil { | 154 | if err != nil { | ||
| 155 | + | _ = c.Close() | |||
| 147 | return nil, err | 156 | return nil, err | ||
| 148 | } | 157 | } | ||
| 149 | - | dialer := net.Dialer{Timeout: dialTimeout} | 158 | + | w.ownsConnection = true |
| 150 | - | conn, err := dialer.DialContext(ctx, "unix", path) | 159 | + | return w, nil |
| 151 | - | if err != nil { | 160 | + | } |
| 152 | - | return nil, fmt.Errorf("failed to connect to Wayland: %w", err) | 161 | + | |
| 162 | + | // NewWindow creates a sibling on this connection. Before StartReader it waits | |||
| 163 | + | // for initial configure/feedback; afterwards it returns unconfigured and the | |||
| 164 | + | // owner must Apply tagged events before allocating/presenting buffers. | |||
| 165 | + | func (c *Connection) NewWindow(ctx context.Context, width, height int32, transparent bool, opts SurfaceOptions) (res *Window, err error) { | |||
| 166 | + | if ctx == nil { | |||
| 167 | + | return nil, errors.New("nil context") | |||
| 153 | } | 168 | } | ||
| 154 | - | d, err := wlturbo.ConnectFromConn(conn) | 169 | + | if width <= 0 || height <= 0 { |
| 155 | - | if err != nil { | 170 | + | return nil, fmt.Errorf("invalid logical size %dx%d", width, height) |
| 171 | + | } | |||
| 172 | + | if err = opts.Validate(); err != nil { | |||
| 173 | + | return nil, err | |||
| 174 | + | } | |||
| 175 | + | if err = ctx.Err(); err != nil { | |||
| 156 | return nil, err | 176 | return nil, err | ||
| 157 | } | 177 | } | ||
| 178 | + | if c.closed { | |||
| 179 | + | return nil, errors.New("connection closed") | |||
| 180 | + | } | |||
| 181 | + | if opts.Lock != nil && (transparent || opts.Layer != nil || opts.InputRects != nil) { | |||
| 182 | + | return nil, errors.New("lock role requires opaque full-input surface") | |||
| 183 | + | } | |||
| 184 | + | if opts.Lock != nil && !c.started.Load() { | |||
| 185 | + | // A lock that was already refused or ended must not get surfaces; its | |||
| 186 | + | // lock and output events are held for the owner, not applied here. | |||
| 187 | + | if err = c.drainStartup(true); err != nil { | |||
| 188 | + | return nil, err | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | // Objects created below get their handlers after their request is sent; | |||
| 192 | + | // a running reader must not dispatch their first events in between. | |||
| 193 | + | defer c.pauseReader()() | |||
| 194 | + | d := c.Display | |||
| 158 | // Closing the display unblocks any pending read; Display.Close is idempotent. | 195 | // Closing the display unblocks any pending read; Display.Close is idempotent. | ||
| 159 | stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() }) | 196 | stopWatch := context.AfterFunc(ctx, func() { _ = d.Close() }) | ||
| 160 | - | w := &Window{events: make(chan Event, 128), readerStop: make(chan struct{}), Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} | 197 | + | w := &Window{connection: c, events: c.events, readerStop: c.stop, Display: d, Width: width, Height: height, Scale: 1, Transparent: transparent} |
| 198 | + | w.readerStarted.Store(c.started.Load()) | |||
| 161 | defer func() { | 199 | defer func() { | ||
| 162 | if !stopWatch() && err == nil { | 200 | if !stopWatch() && err == nil { | ||
| 163 | // Cancelled after the last step but before return: the watcher owns a | 201 | // Cancelled after the last step but before return: the watcher owns a | ||
| @@ -172,9 +210,6 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||||
| 172 | res = nil | 210 | res = nil | ||
| 173 | } | 211 | } | ||
| 174 | }() | 212 | }() | ||
| 175 | - | if err = d.Roundtrip(); err != nil { | |||
| 176 | - | return nil, err | |||
| 177 | - | } | |||
| 178 | wctx := d.Context() | 213 | wctx := d.Context() | ||
| 179 | bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error { | 214 | bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error { | ||
| 180 | advertised, ok := d.Registry().FindGlobal(name) | 215 | advertised, ok := d.Registry().FindGlobal(name) | ||
| @@ -198,7 +233,7 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||||
| 198 | if err = w.bindLayerShell(bind); err != nil { | 233 | if err = w.bindLayerShell(bind); err != nil { | ||
| 199 | return nil, err | 234 | return nil, err | ||
| 200 | } | 235 | } | ||
| 201 | - | } else { | 236 | + | } else if opts.Lock == nil { |
| 202 | w.Shell = xdgshell.NewXdgWmBase(wctx) | 237 | w.Shell = xdgshell.NewXdgWmBase(wctx) | ||
| 203 | if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil { | 238 | if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil { | ||
| 204 | return nil, err | 239 | return nil, err | ||
| @@ -236,9 +271,11 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||||
| 236 | wctx.Unregister(w.CursorShapes) | 271 | wctx.Unregister(w.CursorShapes) | ||
| 237 | w.CursorShapes = nil | 272 | w.CursorShapes = nil | ||
| 238 | } | 273 | } | ||
| 239 | - | if err = w.BindSeat(); err != nil { | 274 | + | w.Seat, w.Pointer, w.WLKeyboard = c.seat.Seat, c.seat.Pointer, c.seat.WLKeyboard |
| 240 | - | return nil, err | 275 | + | if opts.Lock != nil { |
| 241 | - | } | 276 | + | w.Seat = nil |
| 277 | + | } // clipboard/IME constructors require Seat | |||
| 278 | + | w.pointerGen = c.seat.pointerGen | |||
| 242 | if w.Shell != nil { | 279 | if w.Shell != nil { | ||
| 243 | w.Shell.OnPing(func(serial uint32) { | 280 | w.Shell.OnPing(func(serial uint32) { | ||
| 244 | if e := w.Shell.Pong(serial); e != nil { | 281 | if e := w.Shell.Pong(serial); e != nil { | ||
| @@ -290,7 +327,13 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||||
| 290 | w.Dirty = true | 327 | w.Dirty = true | ||
| 291 | } | 328 | } | ||
| 292 | }) | 329 | }) | ||
| 293 | - | if opts.Layer != nil { | 330 | + | c.windows[w.Surface.ID()] = w |
| 331 | + | c.routes.Store(w.Surface.ID(), w) | |||
| 332 | + | if opts.Lock != nil { | |||
| 333 | + | if err = w.createLockSurface(*opts.Lock); err != nil { | |||
| 334 | + | return nil, err | |||
| 335 | + | } | |||
| 336 | + | } else if opts.Layer != nil { | |||
| 294 | if err = w.createLayerSurface(*opts.Layer); err != nil { | 337 | if err = w.createLayerSurface(*opts.Layer); err != nil { | ||
| 295 | return nil, err | 338 | return nil, err | ||
| 296 | } | 339 | } | ||
| @@ -311,16 +354,29 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t | |||||
| 311 | return nil, err | 354 | return nil, err | ||
| 312 | } | 355 | } | ||
| 313 | w.watchFeedback() | 356 | w.watchFeedback() | ||
| 314 | - | if err = w.Surface.Commit(); err != nil { | 357 | + | // ext-session-lock configure is sent by get_lock_surface; unlike xdg and |
| 315 | - | return nil, err | 358 | + | // layer-shell it forbids the initial empty commit before ACK+buffer. |
| 316 | - | } // no buffer before configure | 359 | + | if opts.Lock == nil { |
| 317 | - | for !w.Configured || w.MainDevice == 0 || len(w.Tranches) == 0 { | 360 | + | if err = w.Surface.Commit(); err != nil { |
| 361 | + | return nil, err | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 364 | + | if c.started.Load() { | |||
| 365 | + | return w, nil | |||
| 366 | + | } | |||
| 367 | + | for !w.Configured || !w.FeedbackDone || w.MainDevice == 0 || len(w.Tranches) == 0 { | |||
| 318 | if w.Closed { | 368 | if w.Closed { | ||
| 319 | return nil, fmt.Errorf("window closed before initial configure and feedback") | 369 | return nil, fmt.Errorf("window closed before initial configure and feedback") | ||
| 320 | } | 370 | } | ||
| 371 | + | if err = c.drainStartup(true); err != nil { | |||
| 372 | + | return nil, err // a finished already queued: do not wait for a configure | |||
| 373 | + | } | |||
| 321 | if err = w.Dispatch(); err != nil { | 374 | if err = w.Dispatch(); err != nil { | ||
| 322 | return nil, err | 375 | return nil, err | ||
| 323 | } | 376 | } | ||
| 377 | + | if err = c.drainStartup(true); err != nil { | |||
| 378 | + | return nil, err | |||
| 379 | + | } | |||
| 324 | } | 380 | } | ||
| 325 | return w, nil | 381 | return w, nil | ||
| 326 | } | 382 | } | ||
| @@ -366,6 +422,9 @@ func (w *Window) createToplevel() error { | |||||
| 366 | } | 422 | } | ||
| 367 | 423 | ||||
| 368 | func (w *Window) Dispatch() error { | 424 | func (w *Window) Dispatch() error { | ||
| 425 | + | if w.connection != nil && w.connection.started.Load() { | |||
| 426 | + | return errors.New("connection reader already started") | |||
| 427 | + | } | |||
| 369 | if w.feedbackErr != nil { | 428 | if w.feedbackErr != nil { | ||
| 370 | return w.feedbackErr | 429 | return w.feedbackErr | ||
| 371 | } | 430 | } | ||
| @@ -375,6 +434,14 @@ func (w *Window) Dispatch() error { | |||||
| 375 | return w.feedbackErr | 434 | return w.feedbackErr | ||
| 376 | } | 435 | } | ||
| 377 | 436 | ||||
| 437 | + | // Ready reports that a buffer may be allocated and attached: the role's | |||
| 438 | + | // configure was acknowledged, the dmabuf feedback round is complete and the | |||
| 439 | + | // previous frame callback has fired. | |||
| 440 | + | func (w *Window) Ready() bool { return w.Configured && w.FeedbackDone && w.FrameReady } | |||
| 441 | + | ||||
| 442 | + | // IsLock reports whether the window is an ext-session-lock surface. | |||
| 443 | + | func (w *Window) IsLock() bool { return w != nil && w.LockSurface != nil } | |||
| 444 | + | ||||
| 378 | func (w *Window) PhysicalSize() (int32, int32, error) { | 445 | func (w *Window) PhysicalSize() (int32, int32, error) { | ||
| 379 | if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 { | 446 | if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 { | ||
| 380 | return 0, 0, fmt.Errorf("invalid window geometry") | 447 | return 0, 0, fmt.Errorf("invalid window geometry") | ||
| @@ -396,6 +463,9 @@ func (w *Window) Present(buffer *core.Buffer, acquire, release *drmsyncobj.WpLin | |||||
| 396 | if !w.Configured || buffer == nil || acquire == nil || release == nil { | 463 | if !w.Configured || buffer == nil || acquire == nil || release == nil { | ||
| 397 | return fmt.Errorf("unconfigured surface or missing buffer/timeline") | 464 | return fmt.Errorf("unconfigured surface or missing buffer/timeline") | ||
| 398 | } | 465 | } | ||
| 466 | + | if !w.FeedbackDone { | |||
| 467 | + | return fmt.Errorf("dmabuf feedback incomplete") | |||
| 468 | + | } | |||
| 399 | if !w.FrameReady { | 469 | if !w.FrameReady { | ||
| 400 | return fmt.Errorf("frame callback pending") | 470 | return fmt.Errorf("frame callback pending") | ||
| 401 | } | 471 | } | ||
| @@ -478,15 +548,24 @@ func (w *Window) Close() error { | |||||
| 478 | if w == nil { | 548 | if w == nil { | ||
| 479 | return nil | 549 | return nil | ||
| 480 | } | 550 | } | ||
| 551 | + | if w.ownsConnection && w.connection != nil && !w.connection.closed { | |||
| 552 | + | return w.connection.Close() | |||
| 553 | + | } | |||
| 554 | + | for _, ev := range w.rawInput { | |||
| 555 | + | CloseEventFD(ev) | |||
| 556 | + | } | |||
| 557 | + | clear(w.rawInput) | |||
| 558 | + | w.rawInput = nil | |||
| 481 | // The loop sends protocol destructors before Close; the reader is | 559 | // The loop sends protocol destructors before Close; the reader is | ||
| 482 | // unblocked by the connection close at the end of this method. | 560 | // unblocked by the connection close at the end of this method. | ||
| 483 | if w.inputAdapter != nil { | 561 | if w.inputAdapter != nil { | ||
| 484 | w.inputAdapter.Close() | 562 | w.inputAdapter.Close() | ||
| 485 | w.inputAdapter = nil | 563 | w.inputAdapter = nil | ||
| 486 | } | 564 | } | ||
| 487 | - | if w.Seat != nil { | 565 | + | if w.Seat != nil && w.connection == nil { |
| 488 | _ = w.releaseSeat() | 566 | _ = w.releaseSeat() | ||
| 489 | } | 567 | } | ||
| 568 | + | w.dropCursorDevice() | |||
| 490 | if w.CursorShapes != nil { | 569 | if w.CursorShapes != nil { | ||
| 491 | _ = w.CursorShapes.Destroy() | 570 | _ = w.CursorShapes.Destroy() | ||
| 492 | w.CursorShapes = nil | 571 | w.CursorShapes = nil | ||
| @@ -499,6 +578,14 @@ func (w *Window) Close() error { | |||||
| 499 | _ = w.SyncSurface.Destroy() | 578 | _ = w.SyncSurface.Destroy() | ||
| 500 | w.SyncSurface = nil | 579 | w.SyncSurface = nil | ||
| 501 | } | 580 | } | ||
| 581 | + | if w.connection != nil && w.Surface != nil { | |||
| 582 | + | delete(w.connection.windows, w.Surface.ID()) | |||
| 583 | + | w.connection.routes.Delete(w.Surface.ID()) | |||
| 584 | + | } | |||
| 585 | + | if w.LockSurface != nil { | |||
| 586 | + | _ = w.LockSurface.Destroy() | |||
| 587 | + | w.LockSurface = nil | |||
| 588 | + | } | |||
| 502 | w.closeLayer() // before wl_surface | 589 | w.closeLayer() // before wl_surface | ||
| 503 | if w.Toplevel != nil { | 590 | if w.Toplevel != nil { | ||
| 504 | _ = w.Toplevel.Destroy() | 591 | _ = w.Toplevel.Destroy() | ||
| @@ -548,6 +635,14 @@ func (w *Window) Close() error { | |||||
| 548 | } | 635 | } | ||
| 549 | w.Compositor = nil | 636 | w.Compositor = nil | ||
| 550 | } | 637 | } | ||
| 638 | + | if w.connection != nil { | |||
| 639 | + | w.Display = nil | |||
| 640 | + | w.Closed = true | |||
| 641 | + | if w.ownsConnection { | |||
| 642 | + | return w.connection.Close() | |||
| 643 | + | } | |||
| 644 | + | return nil | |||
| 645 | + | } | |||
| 551 | if w.Display != nil { | 646 | if w.Display != nil { | ||
| 552 | if w.readerDone != nil { | 647 | if w.readerDone != nil { | ||
| 553 | close(w.readerStop) | 648 | close(w.readerStop) | ||
| @@ -609,8 +704,10 @@ func (w *Window) watchFeedback() { | |||||
| 609 | w.Feedback.OnDone(func() { | 704 | w.Feedback.OnDone(func() { | ||
| 610 | if w.readerStarted.Load() { | 705 | if w.readerStarted.Load() { | ||
| 611 | w.post(Event{Kind: FeedbackFormats, Formats: table}) | 706 | w.post(Event{Kind: FeedbackFormats, Formats: table}) | ||
| 707 | + | w.post(Event{Kind: FeedbackComplete}) | |||
| 612 | } else { | 708 | } else { | ||
| 613 | w.Formats = table | 709 | w.Formats = table | ||
| 710 | + | w.FeedbackDone = true | |||
| 614 | } | 711 | } | ||
| 615 | }) | 712 | }) | ||
| 616 | } | 713 | } | ||
M internal/presentation/session/app.go +2 -2
| @@ -62,7 +62,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | - | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | |
| 65 | + | committed, err := sched.Step(s.Window.Ready(), app.Draw, func(prepared render.Frame) (bool, error) { | |
| 66 | 66 | return s.tickFrame(prepared) | |
| 67 | 67 | }) | |
| 68 | 68 | if err != nil { | |
| @@ -77,7 +77,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 77 | 77 | continue | |
| 78 | 78 | } | |
| 79 | 79 | var retry, repeat <-chan time.Time | |
| 80 | - | if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) { | |
| 80 | + | if sched.NeedsRetry(pending || s.framePending, s.Window.Ready()) { | |
| 81 | 81 | retry = time.After(retryInterval) | |
| 82 | 82 | } | |
| 83 | 83 | if adapter := s.Window.InputAdapter(); adapter != nil { | |
| @@ -62,7 +62,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | - | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | 65 | + | committed, err := sched.Step(s.Window.Ready(), app.Draw, func(prepared render.Frame) (bool, error) { |
| 66 | return s.tickFrame(prepared) | 66 | return s.tickFrame(prepared) | ||
| 67 | }) | 67 | }) | ||
| 68 | if err != nil { | 68 | if err != nil { | ||
| @@ -77,7 +77,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 77 | continue | 77 | continue | ||
| 78 | } | 78 | } | ||
| 79 | var retry, repeat <-chan time.Time | 79 | var retry, repeat <-chan time.Time | ||
| 80 | - | if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) { | 80 | + | if sched.NeedsRetry(pending || s.framePending, s.Window.Ready()) { |
| 81 | retry = time.After(retryInterval) | 81 | retry = time.After(retryInterval) | ||
| 82 | } | 82 | } | ||
| 83 | if adapter := s.Window.InputAdapter(); adapter != nil { | 83 | if adapter := s.Window.InputAdapter(); adapter != nil { | ||
A internal/presentation/session/pump.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package session | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/nefergui/internal/render" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Pump drives one Session from an owner loop that multiplexes several sessions | |
| 13 | + | // on a shared Connection. It never reads Wayland events itself: the owner | |
| 14 | + | // applies them and forwards BufferRelease. All methods run on the owner loop. | |
| 15 | + | type Pump struct { | |
| 16 | + | s *Session | |
| 17 | + | sched *frameSchedule | |
| 18 | + | draw func() (render.Frame, bool, error) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewPump prepares s for a shared owner loop. wake is pinged (never blocked on) | |
| 22 | + | // when a GPU release point is ready; the owner then calls Step. draw follows | |
| 23 | + | // the AppHooks.Draw contract. | |
| 24 | + | func (s *Session) NewPump(ctx context.Context, wake chan<- struct{}, draw func() (render.Frame, bool, error)) (*Pump, error) { | |
| 25 | + | if draw == nil { | |
| 26 | + | return nil, errors.New("session: nil draw") | |
| 27 | + | } | |
| 28 | + | if s.waitCancel != nil { | |
| 29 | + | return nil, errors.New("session already running") | |
| 30 | + | } | |
| 31 | + | s.waitCtx, s.waitCancel = context.WithCancel(ctx) | |
| 32 | + | s.releases = make(chan releaseEvent, 128) | |
| 33 | + | s.wake = wake | |
| 34 | + | return &Pump{s: s, sched: newFrameSchedule(), draw: draw}, nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Session returns the pumped session. | |
| 38 | + | func (p *Pump) Session() *Session { return p.s } | |
| 39 | + | ||
| 40 | + | // Invalidate requests a fresh draw at the next Step. | |
| 41 | + | func (p *Pump) Invalidate() { p.sched.Invalidate() } | |
| 42 | + | ||
| 43 | + | // BufferReleased returns a compositor-released wl_buffer to the pool. | |
| 44 | + | func (p *Pump) BufferReleased(id uint64) error { | |
| 45 | + | if slot := p.s.Slots[id]; slot != nil { | |
| 46 | + | if err := p.s.Pool.Release(slot.Buffer); err != nil { | |
| 47 | + | return err | |
| 48 | + | } | |
| 49 | + | p.s.traceState(slot.Buffer, "wl_buffer.release") | |
| 50 | + | } | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Step applies queued GPU releases and submits at most one frame. retry | |
| 55 | + | // reports that the owner must call Step again after retryInterval. | |
| 56 | + | func (p *Pump) Step() (retry bool, err error) { | |
| 57 | + | s := p.s | |
| 58 | + | for done := false; !done; { | |
| 59 | + | select { | |
| 60 | + | case ev := <-s.releases: | |
| 61 | + | if err = s.release(ev); err != nil { | |
| 62 | + | return false, err | |
| 63 | + | } | |
| 64 | + | default: | |
| 65 | + | done = true | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | pending, err := s.finishPending() | |
| 69 | + | if err != nil { | |
| 70 | + | return false, err | |
| 71 | + | } | |
| 72 | + | if s.Window.Dirty { | |
| 73 | + | p.sched.Invalidate() | |
| 74 | + | } | |
| 75 | + | committed, err := p.sched.Step(s.Window.Ready(), p.draw, s.tickFrame) | |
| 76 | + | if err != nil { | |
| 77 | + | return false, err | |
| 78 | + | } | |
| 79 | + | if committed { | |
| 80 | + | return true, nil // the compositor will call back; step again soon for a follow-up | |
| 81 | + | } | |
| 82 | + | return p.sched.NeedsRetry(pending || s.framePending, s.Window.Ready()), nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Close stops release waiters. Call before Session.Close. | |
| 86 | + | func (p *Pump) Close() { | |
| 87 | + | s := p.s | |
| 88 | + | if s.waitCancel != nil { | |
| 89 | + | s.waitCancel() | |
| 90 | + | s.waiters.Wait() | |
| 91 | + | s.waitCancel = nil | |
| 92 | + | } | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package session | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/nefergui/internal/render" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Pump drives one Session from an owner loop that multiplexes several sessions | |||
| 13 | + | // on a shared Connection. It never reads Wayland events itself: the owner | |||
| 14 | + | // applies them and forwards BufferRelease. All methods run on the owner loop. | |||
| 15 | + | type Pump struct { | |||
| 16 | + | s *Session | |||
| 17 | + | sched *frameSchedule | |||
| 18 | + | draw func() (render.Frame, bool, error) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // NewPump prepares s for a shared owner loop. wake is pinged (never blocked on) | |||
| 22 | + | // when a GPU release point is ready; the owner then calls Step. draw follows | |||
| 23 | + | // the AppHooks.Draw contract. | |||
| 24 | + | func (s *Session) NewPump(ctx context.Context, wake chan<- struct{}, draw func() (render.Frame, bool, error)) (*Pump, error) { | |||
| 25 | + | if draw == nil { | |||
| 26 | + | return nil, errors.New("session: nil draw") | |||
| 27 | + | } | |||
| 28 | + | if s.waitCancel != nil { | |||
| 29 | + | return nil, errors.New("session already running") | |||
| 30 | + | } | |||
| 31 | + | s.waitCtx, s.waitCancel = context.WithCancel(ctx) | |||
| 32 | + | s.releases = make(chan releaseEvent, 128) | |||
| 33 | + | s.wake = wake | |||
| 34 | + | return &Pump{s: s, sched: newFrameSchedule(), draw: draw}, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Session returns the pumped session. | |||
| 38 | + | func (p *Pump) Session() *Session { return p.s } | |||
| 39 | + | ||||
| 40 | + | // Invalidate requests a fresh draw at the next Step. | |||
| 41 | + | func (p *Pump) Invalidate() { p.sched.Invalidate() } | |||
| 42 | + | ||||
| 43 | + | // BufferReleased returns a compositor-released wl_buffer to the pool. | |||
| 44 | + | func (p *Pump) BufferReleased(id uint64) error { | |||
| 45 | + | if slot := p.s.Slots[id]; slot != nil { | |||
| 46 | + | if err := p.s.Pool.Release(slot.Buffer); err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | p.s.traceState(slot.Buffer, "wl_buffer.release") | |||
| 50 | + | } | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Step applies queued GPU releases and submits at most one frame. retry | |||
| 55 | + | // reports that the owner must call Step again after retryInterval. | |||
| 56 | + | func (p *Pump) Step() (retry bool, err error) { | |||
| 57 | + | s := p.s | |||
| 58 | + | for done := false; !done; { | |||
| 59 | + | select { | |||
| 60 | + | case ev := <-s.releases: | |||
| 61 | + | if err = s.release(ev); err != nil { | |||
| 62 | + | return false, err | |||
| 63 | + | } | |||
| 64 | + | default: | |||
| 65 | + | done = true | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | pending, err := s.finishPending() | |||
| 69 | + | if err != nil { | |||
| 70 | + | return false, err | |||
| 71 | + | } | |||
| 72 | + | if s.Window.Dirty { | |||
| 73 | + | p.sched.Invalidate() | |||
| 74 | + | } | |||
| 75 | + | committed, err := p.sched.Step(s.Window.Ready(), p.draw, s.tickFrame) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return false, err | |||
| 78 | + | } | |||
| 79 | + | if committed { | |||
| 80 | + | return true, nil // the compositor will call back; step again soon for a follow-up | |||
| 81 | + | } | |||
| 82 | + | return p.sched.NeedsRetry(pending || s.framePending, s.Window.Ready()), nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Close stops release waiters. Call before Session.Close. | |||
| 86 | + | func (p *Pump) Close() { | |||
| 87 | + | s := p.s | |||
| 88 | + | if s.waitCancel != nil { | |||
| 89 | + | s.waitCancel() | |||
| 90 | + | s.waiters.Wait() | |||
| 91 | + | s.waitCancel = nil | |||
| 92 | + | } | |||
| 93 | + | } | |||
M internal/presentation/session/session.go +25 -3
| @@ -76,6 +76,7 @@ type Session struct { | |||
| 76 | 76 | waitCancel context.CancelFunc | |
| 77 | 77 | waiters sync.WaitGroup | |
| 78 | 78 | releases chan releaseEvent | |
| 79 | + | wake chan<- struct{} // optional: pinged after a release is queued | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 81 | 82 | type retiredPipelines struct { | |
| @@ -165,6 +166,17 @@ func OpenWithOptions(ctx context.Context, name string, width, height int32, tran | |||
| 165 | 166 | if err != nil { | |
| 166 | 167 | return nil, err | |
| 167 | 168 | } | |
| 169 | + | return OpenWindow(w, transparent) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // OpenWindow builds the Vulkan/DMA-BUF/explicit-sync path for a window that is | |
| 173 | + | // already configured and has completed dmabuf feedback (a Connection sibling). | |
| 174 | + | // On error the window is closed. The session owns w from here on. | |
| 175 | + | func OpenWindow(w *wayland.Window, transparent bool) (_ *Session, err error) { | |
| 176 | + | if !w.Configured || !w.FeedbackDone { | |
| 177 | + | _ = w.Close() | |
| 178 | + | return nil, errors.New("session: window is not configured with complete dmabuf feedback") | |
| 179 | + | } | |
| 168 | 180 | s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)} | |
| 169 | 181 | if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" { | |
| 170 | 182 | s.debugDir = dir | |
| @@ -526,6 +538,12 @@ func (s *Session) startReleaseWait(slot *Slot, point uint64) { | |||
| 526 | 538 | if err != nil || ready { | |
| 527 | 539 | select { | |
| 528 | 540 | case s.releases <- releaseEvent{id: id, point: point, err: err}: | |
| 541 | + | if s.wake != nil { | |
| 542 | + | select { | |
| 543 | + | case s.wake <- struct{}{}: | |
| 544 | + | default: | |
| 545 | + | } | |
| 546 | + | } | |
| 529 | 547 | case <-ctx.Done(): | |
| 530 | 548 | } | |
| 531 | 549 | return | |
| @@ -808,8 +826,12 @@ func (s *Session) Close() (err error) { | |||
| 808 | 826 | }() | |
| 809 | 827 | } | |
| 810 | 828 | if s.Window != nil && s.Window.Surface != nil { | |
| 811 | - | _ = s.Window.Surface.Attach(nil, 0, 0) | |
| 812 | - | _ = s.Window.Surface.Commit() | |
| 829 | + | // A null-buffer commit is a protocol error on an ext-session-lock | |
| 830 | + | // surface; its destruction hides it instead. | |
| 831 | + | if !s.Window.IsLock() { | |
| 832 | + | _ = s.Window.Surface.Attach(nil, 0, 0) | |
| 833 | + | _ = s.Window.Surface.Commit() | |
| 834 | + | } | |
| 813 | 835 | _ = s.Window.DestroySyncSurface() | |
| 814 | 836 | } | |
| 815 | 837 | // Drain detach/release points for tracing, not for freeing allocations. The | |
| @@ -831,7 +853,7 @@ func (s *Session) Close() (err error) { | |||
| 831 | 853 | select { | |
| 832 | 854 | case ev := <-s.releases: | |
| 833 | 855 | _ = s.release(ev) | |
| 834 | - | case ev := <-s.Window.Events(): | |
| 856 | + | case ev := <-s.Window.ReaderEvents(): // nil on a shared connection | |
| 835 | 857 | if ev.Kind == wayland.BufferRelease { | |
| 836 | 858 | if slot := s.Slots[ev.BufferID]; slot != nil { | |
| 837 | 859 | _ = s.Pool.Release(slot.Buffer) | |
| @@ -76,6 +76,7 @@ type Session struct { | |||||
| 76 | waitCancel context.CancelFunc | 76 | waitCancel context.CancelFunc | ||
| 77 | waiters sync.WaitGroup | 77 | waiters sync.WaitGroup | ||
| 78 | releases chan releaseEvent | 78 | releases chan releaseEvent | ||
| 79 | + | wake chan<- struct{} // optional: pinged after a release is queued | |||
| 79 | } | 80 | } | ||
| 80 | 81 | ||||
| 81 | type retiredPipelines struct { | 82 | type retiredPipelines struct { | ||
| @@ -165,6 +166,17 @@ func OpenWithOptions(ctx context.Context, name string, width, height int32, tran | |||||
| 165 | if err != nil { | 166 | if err != nil { | ||
| 166 | return nil, err | 167 | return nil, err | ||
| 167 | } | 168 | } | ||
| 169 | + | return OpenWindow(w, transparent) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // OpenWindow builds the Vulkan/DMA-BUF/explicit-sync path for a window that is | |||
| 173 | + | // already configured and has completed dmabuf feedback (a Connection sibling). | |||
| 174 | + | // On error the window is closed. The session owns w from here on. | |||
| 175 | + | func OpenWindow(w *wayland.Window, transparent bool) (_ *Session, err error) { | |||
| 176 | + | if !w.Configured || !w.FeedbackDone { | |||
| 177 | + | _ = w.Close() | |||
| 178 | + | return nil, errors.New("session: window is not configured with complete dmabuf feedback") | |||
| 179 | + | } | |||
| 168 | s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)} | 180 | s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)} | ||
| 169 | if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" { | 181 | if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" { | ||
| 170 | s.debugDir = dir | 182 | s.debugDir = dir | ||
| @@ -526,6 +538,12 @@ func (s *Session) startReleaseWait(slot *Slot, point uint64) { | |||||
| 526 | if err != nil || ready { | 538 | if err != nil || ready { | ||
| 527 | select { | 539 | select { | ||
| 528 | case s.releases <- releaseEvent{id: id, point: point, err: err}: | 540 | case s.releases <- releaseEvent{id: id, point: point, err: err}: | ||
| 541 | + | if s.wake != nil { | |||
| 542 | + | select { | |||
| 543 | + | case s.wake <- struct{}{}: | |||
| 544 | + | default: | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 529 | case <-ctx.Done(): | 547 | case <-ctx.Done(): | ||
| 530 | } | 548 | } | ||
| 531 | return | 549 | return | ||
| @@ -808,8 +826,12 @@ func (s *Session) Close() (err error) { | |||||
| 808 | }() | 826 | }() | ||
| 809 | } | 827 | } | ||
| 810 | if s.Window != nil && s.Window.Surface != nil { | 828 | if s.Window != nil && s.Window.Surface != nil { | ||
| 811 | - | _ = s.Window.Surface.Attach(nil, 0, 0) | 829 | + | // A null-buffer commit is a protocol error on an ext-session-lock |
| 812 | - | _ = s.Window.Surface.Commit() | 830 | + | // surface; its destruction hides it instead. |
| 831 | + | if !s.Window.IsLock() { | |||
| 832 | + | _ = s.Window.Surface.Attach(nil, 0, 0) | |||
| 833 | + | _ = s.Window.Surface.Commit() | |||
| 834 | + | } | |||
| 813 | _ = s.Window.DestroySyncSurface() | 835 | _ = s.Window.DestroySyncSurface() | ||
| 814 | } | 836 | } | ||
| 815 | // Drain detach/release points for tracing, not for freeing allocations. The | 837 | // Drain detach/release points for tracing, not for freeing allocations. The | ||
| @@ -831,7 +853,7 @@ func (s *Session) Close() (err error) { | |||||
| 831 | select { | 853 | select { | ||
| 832 | case ev := <-s.releases: | 854 | case ev := <-s.releases: | ||
| 833 | _ = s.release(ev) | 855 | _ = s.release(ev) | ||
| 834 | - | case ev := <-s.Window.Events(): | 856 | + | case ev := <-s.Window.ReaderEvents(): // nil on a shared connection |
| 835 | if ev.Kind == wayland.BufferRelease { | 857 | if ev.Kind == wayland.BufferRelease { | ||
| 836 | if slot := s.Slots[ev.BufferID]; slot != nil { | 858 | if slot := s.Slots[ev.BufferID]; slot != nil { | ||
| 837 | _ = s.Pool.Release(slot.Buffer) | 859 | _ = s.Pool.Release(slot.Buffer) | ||
A internal/ui/lock.go +594 -0
| @@ -0,0 +1,594 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "slices" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefergui/internal/css" | |
| 15 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 16 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 17 | + | "github.com/bnema/nefergui/internal/presentation/session" | |
| 18 | + | "github.com/bnema/nefergui/internal/render" | |
| 19 | + | "github.com/bnema/nefergui/internal/text" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // ErrLockFinished reports that the compositor refused the session lock or | |
| 23 | + | // ended it (ext_session_lock_v1.finished). NeferGUI never unlocks in response. | |
| 24 | + | var ErrLockFinished = errors.New("nefergui: session lock finished by the compositor") | |
| 25 | + | ||
| 26 | + | // SecretBuffer is a caller-owned fixed-capacity byte buffer for typed secret | |
| 27 | + | // input. Typed bytes never become a Go string and are never stored in the | |
| 28 | + | // model, tree or render text; the view only sees Len. Bytes beyond the | |
| 29 | + | // capacity are dropped. It is used from the lock owner loop only. | |
| 30 | + | type SecretBuffer struct { | |
| 31 | + | buf []byte | |
| 32 | + | n int | |
| 33 | + | runes int | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // NewSecretBuffer allocates a buffer holding at most capacity bytes (1..4096). | |
| 37 | + | func NewSecretBuffer(capacity int) *SecretBuffer { | |
| 38 | + | if capacity < 1 || capacity > 4096 { | |
| 39 | + | panic("nefergui: secret buffer capacity must be between 1 and 4096") | |
| 40 | + | } | |
| 41 | + | return &SecretBuffer{buf: make([]byte, capacity)} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Len is the number of typed code points (the mask count). | |
| 45 | + | func (b *SecretBuffer) Len() int { return b.runes } | |
| 46 | + | ||
| 47 | + | // Bytes returns the typed bytes. The slice aliases the buffer: do not retain | |
| 48 | + | // it past the next edit or Wipe, and never convert it to a string. | |
| 49 | + | func (b *SecretBuffer) Bytes() []byte { return b.buf[:b.n:b.n] } | |
| 50 | + | ||
| 51 | + | // Wipe zeroes the buffer. The caller may call it at any time on the owner loop. | |
| 52 | + | func (b *SecretBuffer) Wipe() { | |
| 53 | + | clear(b.buf) | |
| 54 | + | b.n, b.runes = 0, 0 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (b *SecretBuffer) append(p []byte) { | |
| 58 | + | if len(p) == 0 || len(p) > len(b.buf)-b.n || !utf8.Valid(p) { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | b.n += copy(b.buf[b.n:], p) | |
| 62 | + | b.runes += utf8.RuneCount(p) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (b *SecretBuffer) backspace() { | |
| 66 | + | if b.n == 0 { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | _, size := utf8.DecodeLastRune(b.buf[:b.n]) | |
| 70 | + | clear(b.buf[b.n-size : b.n]) | |
| 71 | + | b.n -= size | |
| 72 | + | b.runes-- | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // LockStatus is an opaque caller-driven state shown by the view, for example | |
| 76 | + | // while an authentication attempt runs. NeferGUI attaches no meaning to it. | |
| 77 | + | type LockStatus uint8 | |
| 78 | + | ||
| 79 | + | const ( | |
| 80 | + | LockIdle LockStatus = iota // ready for input | |
| 81 | + | LockBusy // an attempt is being verified | |
| 82 | + | LockFailed // the last attempt was rejected | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | // LockState is what the lock view may know: never the secret itself. | |
| 86 | + | type LockState struct { | |
| 87 | + | Mask int // typed code points, for a mask of that many bullets | |
| 88 | + | Locked bool // the compositor confirmed the lock (locked event) | |
| 89 | + | Status LockStatus // latest value received on LockConfig.Status | |
| 90 | + | Width, Height int // logical size of the surface hosting the view | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Bullets returns Mask bullet characters. | |
| 94 | + | func (s LockState) Bullets() string { return strings.Repeat("•", s.Mask) } | |
| 95 | + | ||
| 96 | + | // LockConfig configures RunLock. | |
| 97 | + | type LockConfig struct { | |
| 98 | + | // Display is the Wayland socket name; empty uses the environment. | |
| 99 | + | Display string | |
| 100 | + | // Styles is an optional author stylesheet path, as for the Styles option. | |
| 101 | + | Styles string | |
| 102 | + | // Secret receives typed input. Required. | |
| 103 | + | Secret *SecretBuffer | |
| 104 | + | // View builds the content of the one output that hosts it (the output whose | |
| 105 | + | // surface has keyboard focus, initially the first). Required. Other outputs | |
| 106 | + | // show plain opaque black. | |
| 107 | + | View func(*Frame, LockState) | |
| 108 | + | // OnLocked runs once on the owner loop when the compositor sends locked. | |
| 109 | + | OnLocked func() | |
| 110 | + | // OnSubmit runs on the owner loop when Enter is pressed after locked. It | |
| 111 | + | // must not block: verification is asynchronous. secret aliases Secret's | |
| 112 | + | // memory and is valid only during the call; it is wiped right after it | |
| 113 | + | // returns, so the callee copies or consumes it before returning. | |
| 114 | + | OnSubmit func(secret []byte) | |
| 115 | + | // Unlock, when it receives a value, asks RunLock to send | |
| 116 | + | // unlock_and_destroy, roundtrip and return nil. This is the only unlock | |
| 117 | + | // path: it is the caller's explicit request after its own verification. A | |
| 118 | + | // request received before the locked event is held until locked; closing | |
| 119 | + | // the channel is not a request. A nil channel never unlocks. | |
| 120 | + | Unlock <-chan struct{} | |
| 121 | + | // Status delivers the state shown as LockState.Status; each received value | |
| 122 | + | // schedules a redraw. Optional. | |
| 123 | + | Status <-chan LockStatus | |
| 124 | + | // Wake requests a redraw for each received value. | |
| 125 | + | Wake <-chan struct{} | |
| 126 | + | // OnOutputError reports an output added after acquisition that could not | |
| 127 | + | // get a lock surface; the compositor keeps it black. Optional, owner loop. | |
| 128 | + | OnOutputError func(output string, err error) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | type lockSurface struct { | |
| 132 | + | w *wayland.Window | |
| 133 | + | output uint32 | |
| 134 | + | s *session.Session | |
| 135 | + | p *session.Pump | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // RunLock acquires ext-session-lock on one connection and shows opaque black on | |
| 139 | + | // every output through the regular Vulkan/DMA-BUF/explicit-sync path. Outputs | |
| 140 | + | // added or removed later gain or lose a lock surface. Keyboard input feeds | |
| 141 | + | // cfg.Secret only through the secret keyboard path. RunLock returns nil after | |
| 142 | + | // an unlock requested through cfg.Unlock, ErrLockFinished (wrapped) when the | |
| 143 | + | // compositor ends or refuses the lock, or the first failure; it never unlocks | |
| 144 | + | // on failure or cancellation (closing the connection keeps the session locked). | |
| 145 | + | func RunLock(ctx context.Context, cfg LockConfig) (err error) { | |
| 146 | + | if cfg.Secret == nil || cfg.View == nil { | |
| 147 | + | return errors.New("nefergui: lock needs Secret and View") | |
| 148 | + | } | |
| 149 | + | if err = ctx.Err(); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 152 | + | defer cfg.Secret.Wipe() // also on panic and every early return | |
| 153 | + | ctx, stop := context.WithCancel(ctx) | |
| 154 | + | defer stop() // ends the wake forwarder with RunLock | |
| 155 | + | r := newRuntime() | |
| 156 | + | if cfg.Styles != "" { | |
| 157 | + | data, e := os.ReadFile(cfg.Styles) | |
| 158 | + | if e != nil { | |
| 159 | + | return fmt.Errorf("nefergui: styles: %w", e) | |
| 160 | + | } | |
| 161 | + | r.styles = css.Compile(css.UA(), css.Parse(string(data))) | |
| 162 | + | } | |
| 163 | + | catalog, err := text.Load(text.SystemSource{}) | |
| 164 | + | if err != nil { | |
| 165 | + | return fmt.Errorf("nefergui: fonts: %w", err) | |
| 166 | + | } | |
| 167 | + | if len(catalog.Faces) == 0 { | |
| 168 | + | return errors.New("nefergui: no usable fonts") | |
| 169 | + | } | |
| 170 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 171 | + | conn, err := wayland.ConnectConnection(ctx, cfg.Display) | |
| 172 | + | if err != nil { | |
| 173 | + | return fmt.Errorf("nefergui: open platform: %w", err) | |
| 174 | + | } | |
| 175 | + | l := &locker{cfg: cfg, r: r, conn: conn, ctx: ctx, surfaces: map[*wayland.Window]*lockSurface{}, pending: map[*wayland.Window]pendingLock{}, covered: map[uint32]bool{}, initial: map[uint32]bool{}, gpuWake: make(chan struct{}, 1), scratch: make([]byte, 128)} | |
| 176 | + | defer func() { | |
| 177 | + | l.closeAll() | |
| 178 | + | clear(l.scratch) | |
| 179 | + | if l.kb != nil { | |
| 180 | + | l.kb.Close() | |
| 181 | + | } | |
| 182 | + | err = errors.Join(err, conn.Close()) | |
| 183 | + | }() | |
| 184 | + | if l.kb, err = keyboard.New(""); err != nil { | |
| 185 | + | return err | |
| 186 | + | } | |
| 187 | + | l.kb.Focus(false) | |
| 188 | + | if err = conn.AcquireLock(); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | // Surfaces for the outputs present now are created before the reader starts | |
| 192 | + | // so the compositor can see them while it decides to send locked. | |
| 193 | + | if err = l.reconcile(true); err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | events := conn.StartReader() | |
| 197 | + | // Lock and output events that arrived while the first surfaces were set up | |
| 198 | + | // were kept for us: locked must enable unlock, finished must fail the run. | |
| 199 | + | for _, ev := range conn.TakeHeld() { | |
| 200 | + | if err = l.handle(ev); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | if cfg.Wake != nil { | |
| 205 | + | go r.forwardWake(ctx, cfg.Wake) | |
| 206 | + | } | |
| 207 | + | return l.loop(events) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // pendingLock is a created lock surface and the output it covers. | |
| 211 | + | type pendingLock struct { | |
| 212 | + | output uint32 | |
| 213 | + | name string // connector name, for error reports | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | type locker struct { | |
| 217 | + | cfg LockConfig | |
| 218 | + | r *runtime | |
| 219 | + | conn *wayland.Connection | |
| 220 | + | ctx context.Context | |
| 221 | + | kb *keyboard.Keyboard | |
| 222 | + | scratch []byte | |
| 223 | + | surfaces map[*wayland.Window]*lockSurface | |
| 224 | + | pending map[*wayland.Window]pendingLock // created, not yet configured with feedback | |
| 225 | + | covered map[uint32]bool // output globals with a surface or a pending one | |
| 226 | + | initial map[uint32]bool // outputs present at acquisition: their failures abort | |
| 227 | + | host *lockSurface | |
| 228 | + | locked bool | |
| 229 | + | status LockStatus | |
| 230 | + | unlock bool // the caller asked for unlock; honoured once locked | |
| 231 | + | gpuWake chan struct{} | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func (l *locker) loop(events <-chan wayland.Event) error { | |
| 235 | + | for { | |
| 236 | + | if err := l.promote(); err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | retry := false | |
| 240 | + | for _, ls := range l.surfaces { | |
| 241 | + | again, err := ls.p.Step() | |
| 242 | + | if err != nil { | |
| 243 | + | return err | |
| 244 | + | } | |
| 245 | + | retry = retry || again | |
| 246 | + | } | |
| 247 | + | var retryC, repeatC <-chan time.Time | |
| 248 | + | if retry { | |
| 249 | + | retryC = time.After(5 * time.Millisecond) | |
| 250 | + | } | |
| 251 | + | if at, ok := l.kb.NextRepeat(); ok { | |
| 252 | + | repeatC = time.After(time.Until(at)) | |
| 253 | + | } | |
| 254 | + | select { | |
| 255 | + | case <-l.ctx.Done(): | |
| 256 | + | return l.ctx.Err() | |
| 257 | + | case <-l.r.wake: | |
| 258 | + | l.invalidateHost() | |
| 259 | + | case _, ok := <-l.cfg.Unlock: | |
| 260 | + | if ok { | |
| 261 | + | l.unlock = true | |
| 262 | + | } else { | |
| 263 | + | l.cfg.Unlock = nil // closing is not a request | |
| 264 | + | } | |
| 265 | + | case st, ok := <-l.cfg.Status: | |
| 266 | + | if ok { | |
| 267 | + | l.status = st | |
| 268 | + | l.secretChanged() | |
| 269 | + | } else { | |
| 270 | + | l.cfg.Status = nil | |
| 271 | + | } | |
| 272 | + | case <-l.gpuWake: | |
| 273 | + | case <-retryC: | |
| 274 | + | case <-repeatC: | |
| 275 | + | if _, err := l.kb.TickSecret(time.Now(), l.scratch, l.consume); err != nil { | |
| 276 | + | return err | |
| 277 | + | } | |
| 278 | + | l.secretChanged() | |
| 279 | + | case ev := <-events: | |
| 280 | + | if err := l.handle(ev); err != nil { | |
| 281 | + | return err | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | if l.shouldUnlock() { | |
| 285 | + | return l.finishUnlock() | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func (l *locker) handle(ev wayland.Event) error { | |
| 291 | + | switch ev.Kind { | |
| 292 | + | case wayland.BufferRelease: | |
| 293 | + | if ls := l.surfaces[ev.Window]; ls != nil { | |
| 294 | + | return ls.p.BufferReleased(ev.BufferID) | |
| 295 | + | } | |
| 296 | + | return nil | |
| 297 | + | case wayland.OutputRemoved: | |
| 298 | + | l.removeOutput(ev.Output.Global) | |
| 299 | + | } | |
| 300 | + | if err := l.conn.Apply(ev); err != nil { | |
| 301 | + | return err | |
| 302 | + | } | |
| 303 | + | switch ev.Kind { | |
| 304 | + | case wayland.LockAcquired: | |
| 305 | + | l.locked = true | |
| 306 | + | l.invalidateHost() | |
| 307 | + | if l.cfg.OnLocked != nil { | |
| 308 | + | l.cfg.OnLocked() | |
| 309 | + | } | |
| 310 | + | case wayland.LockFinished: | |
| 311 | + | return ErrLockFinished | |
| 312 | + | case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded: | |
| 313 | + | if err := l.reconcile(false); err != nil { | |
| 314 | + | return err | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | return l.drainInput() | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // reconcile gives every named output without one a lock surface. A failure on | |
| 321 | + | // an initial output aborts the lock. A hotplugged output that cannot get a | |
| 322 | + | // surface is logged and left alone: the compositor keeps it black regardless. | |
| 323 | + | func (l *locker) reconcile(initial bool) error { | |
| 324 | + | for _, o := range l.conn.Outputs() { | |
| 325 | + | if l.covered[o.Global] { | |
| 326 | + | continue | |
| 327 | + | } | |
| 328 | + | l.covered[o.Global] = true // also on failure: never retried for this lifetime | |
| 329 | + | w, err := l.conn.NewWindow(l.ctx, 1, 1, false, wayland.SurfaceOptions{Lock: &wayland.LockOptions{Output: o.Global}}) | |
| 330 | + | if err != nil { | |
| 331 | + | if err = l.outputFailure(initial, "lock surface", o.Name, err); err != nil { | |
| 332 | + | return err | |
| 333 | + | } | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | if initial { | |
| 337 | + | l.initial[o.Global] = true | |
| 338 | + | } | |
| 339 | + | l.pending[w] = pendingLock{output: o.Global, name: o.Name} | |
| 340 | + | } | |
| 341 | + | return nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // promote opens the Vulkan session of every window whose configure was | |
| 345 | + | // acknowledged and whose dmabuf feedback round is complete, in ascending | |
| 346 | + | // output global order so the first host is deterministic. | |
| 347 | + | func (l *locker) promote() error { | |
| 348 | + | ready := make([]*wayland.Window, 0, len(l.pending)) | |
| 349 | + | for w := range l.pending { | |
| 350 | + | if w.Configured && w.FeedbackDone { | |
| 351 | + | ready = append(ready, w) | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | slices.SortFunc(ready, func(a, b *wayland.Window) int { return cmp.Compare(l.pending[a].output, l.pending[b].output) }) | |
| 355 | + | for _, w := range ready { | |
| 356 | + | pl := l.pending[w] | |
| 357 | + | output := pl.output | |
| 358 | + | delete(l.pending, w) | |
| 359 | + | ls, err := l.open(w, output) // closes w on failure | |
| 360 | + | if err != nil { | |
| 361 | + | if err = l.outputFailure(l.initial[output], "lock render path", pl.name, err); err != nil { | |
| 362 | + | return err | |
| 363 | + | } | |
| 364 | + | continue | |
| 365 | + | } | |
| 366 | + | l.surfaces[w] = ls | |
| 367 | + | if l.host == nil { | |
| 368 | + | l.setHost(ls) | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | return nil | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // outputFailure applies the per-output failure policy. A refused or ended lock | |
| 375 | + | // always aborts (as ErrLockFinished). Otherwise only outputs present at | |
| 376 | + | // acquisition abort; a hotplugged output is skipped, since the | |
| 377 | + | // compositor keeps it black without our surface; OnOutputError reports it. | |
| 378 | + | func (l *locker) outputFailure(initial bool, what, output string, err error) error { | |
| 379 | + | if abort := outputFailure(initial, what, output, err); abort != nil { | |
| 380 | + | return abort | |
| 381 | + | } | |
| 382 | + | if l.cfg.OnOutputError != nil { | |
| 383 | + | l.cfg.OnOutputError(output, fmt.Errorf("%s: %w", what, err)) | |
| 384 | + | } | |
| 385 | + | return nil | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func outputFailure(initial bool, what, output string, err error) error { | |
| 389 | + | if errors.Is(err, wayland.ErrLockFinished) { | |
| 390 | + | return fmt.Errorf("%w: %w", ErrLockFinished, err) | |
| 391 | + | } | |
| 392 | + | if initial { | |
| 393 | + | return fmt.Errorf("nefergui: %s for output %s: %w", what, output, err) | |
| 394 | + | } | |
| 395 | + | return nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // open builds the Vulkan session and frame pump of a configured lock window. | |
| 399 | + | func (l *locker) open(w *wayland.Window, output uint32) (*lockSurface, error) { | |
| 400 | + | s, err := session.OpenWindow(w, false) | |
| 401 | + | if err != nil { | |
| 402 | + | return nil, err | |
| 403 | + | } | |
| 404 | + | ls := &lockSurface{w: w, output: output, s: s} | |
| 405 | + | if ls.p, err = s.NewPump(l.ctx, l.gpuWake, func() (render.Frame, bool, error) { return l.draw(ls) }); err != nil { | |
| 406 | + | _ = s.Close() | |
| 407 | + | return nil, err | |
| 408 | + | } | |
| 409 | + | return ls, nil | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func (l *locker) removeOutput(global uint32) { | |
| 413 | + | delete(l.covered, global) | |
| 414 | + | for w, pl := range l.pending { | |
| 415 | + | if pl.output == global { | |
| 416 | + | delete(l.pending, w) | |
| 417 | + | _ = w.Close() | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | for w, ls := range l.surfaces { | |
| 421 | + | if ls.output != global { | |
| 422 | + | continue | |
| 423 | + | } | |
| 424 | + | delete(l.surfaces, w) | |
| 425 | + | ls.close() | |
| 426 | + | if l.host == ls { | |
| 427 | + | l.host = nil | |
| 428 | + | var next *lockSurface | |
| 429 | + | for _, o := range l.surfaces { | |
| 430 | + | if next == nil || o.output < next.output { | |
| 431 | + | next = o | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | if next != nil { | |
| 435 | + | l.setHost(next) | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func (ls *lockSurface) close() { | |
| 442 | + | ls.p.Close() | |
| 443 | + | _ = ls.s.Close() | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (l *locker) closeAll() { | |
| 447 | + | for w, ls := range l.surfaces { | |
| 448 | + | delete(l.surfaces, w) | |
| 449 | + | ls.close() | |
| 450 | + | } | |
| 451 | + | for w := range l.pending { | |
| 452 | + | delete(l.pending, w) | |
| 453 | + | _ = w.Close() | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (l *locker) setHost(ls *lockSurface) { | |
| 458 | + | if l.host == ls { | |
| 459 | + | return | |
| 460 | + | } | |
| 461 | + | old := l.host | |
| 462 | + | l.host = ls | |
| 463 | + | if old != nil { | |
| 464 | + | old.p.Invalidate() | |
| 465 | + | } | |
| 466 | + | l.r.Redraw() | |
| 467 | + | ls.p.Invalidate() | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (l *locker) invalidateHost() { | |
| 471 | + | if l.host != nil { | |
| 472 | + | l.host.p.Invalidate() | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // draw shows the view on the host output and plain black elsewhere. An empty | |
| 477 | + | // frame is the opaque clear color; alpha is not part of the XRGB buffer. | |
| 478 | + | func (l *locker) draw(ls *lockSurface) (render.Frame, bool, error) { | |
| 479 | + | if l.host != ls { | |
| 480 | + | return render.Frame{}, true, nil | |
| 481 | + | } | |
| 482 | + | w := ls.w | |
| 483 | + | l.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale}) | |
| 484 | + | state := LockState{Mask: l.cfg.Secret.Len(), Locked: l.locked, Status: l.status, Width: int(w.Width), Height: int(w.Height)} | |
| 485 | + | if !l.r.Build(func(f *Frame) { l.cfg.View(f, state) }) { | |
| 486 | + | return render.Frame{}, false, nil | |
| 487 | + | } | |
| 488 | + | pw, ph, err := w.PhysicalSize() | |
| 489 | + | if err != nil { | |
| 490 | + | return render.Frame{}, false, err | |
| 491 | + | } | |
| 492 | + | frame, err := ls.s.Preparer.Prepare(l.r.output.Display, w.Scale, int(pw), int(ph)) | |
| 493 | + | return frame, true, err | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // drainInput feeds raw keyboard protocol events from every lock window to the | |
| 497 | + | // secret keyboard. Nothing here creates a string or reaches the UI runtime. | |
| 498 | + | func (l *locker) drainInput() error { | |
| 499 | + | take := func(w *wayland.Window) error { | |
| 500 | + | events := w.TakeInputEvents() | |
| 501 | + | for i, e := range events { | |
| 502 | + | if err := l.input(w, e); err != nil { | |
| 503 | + | for _, rest := range events[i+1:] { | |
| 504 | + | wayland.CloseEventFD(rest) // never leak keymap FDs | |
| 505 | + | } | |
| 506 | + | return err | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | return nil | |
| 510 | + | } | |
| 511 | + | for w := range l.surfaces { | |
| 512 | + | if err := take(w); err != nil { | |
| 513 | + | return err | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | for w := range l.pending { | |
| 517 | + | if err := take(w); err != nil { | |
| 518 | + | return err | |
| 519 | + | } | |
| 520 | + | } | |
| 521 | + | return nil | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | func (l *locker) input(w *wayland.Window, e wayland.Event) error { | |
| 525 | + | switch e.Kind { | |
| 526 | + | case wayland.InputKeymap: | |
| 527 | + | if e.FD < 0 { | |
| 528 | + | return errors.New("nefergui: missing keymap fd") | |
| 529 | + | } | |
| 530 | + | if e.Format != 1 || e.Size == 0 || e.Size > 16<<20 { | |
| 531 | + | wayland.CloseEventFD(e) | |
| 532 | + | return nil | |
| 533 | + | } | |
| 534 | + | return l.kb.ReplaceFD(e.FD, int(e.Size)) | |
| 535 | + | case wayland.InputModifiers: | |
| 536 | + | _ = l.kb.Mask(e.Mods[0], e.Mods[1], e.Mods[2], 0, 0, e.Mods[3]) // no keymap yet: ignore | |
| 537 | + | case wayland.InputRepeatInfo: | |
| 538 | + | l.kb.RepeatInfo(int(e.Rate), time.Duration(e.Delay)*time.Millisecond) | |
| 539 | + | case wayland.InputFocusIn: | |
| 540 | + | l.kb.Focus(true) | |
| 541 | + | if ls := l.surfaces[w]; ls != nil { | |
| 542 | + | l.setHost(ls) | |
| 543 | + | } | |
| 544 | + | case wayland.InputFocusOut: | |
| 545 | + | l.kb.ClearSecret() | |
| 546 | + | l.kb.Focus(false) | |
| 547 | + | case wayland.InputKey: | |
| 548 | + | if err := l.kb.EventSecret(e.Code, e.Pressed, time.Now(), l.scratch, l.consume); err != nil { | |
| 549 | + | return err | |
| 550 | + | } | |
| 551 | + | l.secretChanged() | |
| 552 | + | } | |
| 553 | + | return nil | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | func (l *locker) secretChanged() { | |
| 557 | + | l.r.Redraw() | |
| 558 | + | l.invalidateHost() | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | // consume applies one secret key: bytes append, Backspace removes a code | |
| 562 | + | // point, Escape and Ctrl+U clear, Enter submits. | |
| 563 | + | func (l *locker) consume(k keyboard.SecretKey, chunk []byte) { | |
| 564 | + | if !k.Pressed { | |
| 565 | + | return | |
| 566 | + | } | |
| 567 | + | b := l.cfg.Secret | |
| 568 | + | switch k.Kind { | |
| 569 | + | case keyboard.SecretBytes: | |
| 570 | + | b.append(chunk) | |
| 571 | + | case keyboard.SecretBackspace: | |
| 572 | + | b.backspace() | |
| 573 | + | case keyboard.SecretEscape, keyboard.SecretClear: | |
| 574 | + | b.Wipe() | |
| 575 | + | case keyboard.SecretReturn: | |
| 576 | + | // Before the locked event Enter only clears. Submitting never unlocks. | |
| 577 | + | defer b.Wipe() // even if OnSubmit panics | |
| 578 | + | if l.locked && l.cfg.OnSubmit != nil && b.Len() > 0 { | |
| 579 | + | l.cfg.OnSubmit(b.Bytes()) | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // shouldUnlock reports that the caller asked for unlock and the lock is held. | |
| 585 | + | func (l *locker) shouldUnlock() bool { return l.unlock && l.locked } | |
| 586 | + | ||
| 587 | + | // finishUnlock is reached only after the caller sent on cfg.Unlock. | |
| 588 | + | func (l *locker) finishUnlock() error { | |
| 589 | + | if err := l.conn.UnlockAndDestroy(); err != nil { // includes the display roundtrip | |
| 590 | + | return err | |
| 591 | + | } | |
| 592 | + | l.closeAll() | |
| 593 | + | return nil | |
| 594 | + | } | |
| @@ -0,0 +1,594 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "slices" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | "unicode/utf8" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/nefergui/internal/css" | |||
| 15 | + | "github.com/bnema/nefergui/internal/keyboard" | |||
| 16 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |||
| 17 | + | "github.com/bnema/nefergui/internal/presentation/session" | |||
| 18 | + | "github.com/bnema/nefergui/internal/render" | |||
| 19 | + | "github.com/bnema/nefergui/internal/text" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // ErrLockFinished reports that the compositor refused the session lock or | |||
| 23 | + | // ended it (ext_session_lock_v1.finished). NeferGUI never unlocks in response. | |||
| 24 | + | var ErrLockFinished = errors.New("nefergui: session lock finished by the compositor") | |||
| 25 | + | ||||
| 26 | + | // SecretBuffer is a caller-owned fixed-capacity byte buffer for typed secret | |||
| 27 | + | // input. Typed bytes never become a Go string and are never stored in the | |||
| 28 | + | // model, tree or render text; the view only sees Len. Bytes beyond the | |||
| 29 | + | // capacity are dropped. It is used from the lock owner loop only. | |||
| 30 | + | type SecretBuffer struct { | |||
| 31 | + | buf []byte | |||
| 32 | + | n int | |||
| 33 | + | runes int | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // NewSecretBuffer allocates a buffer holding at most capacity bytes (1..4096). | |||
| 37 | + | func NewSecretBuffer(capacity int) *SecretBuffer { | |||
| 38 | + | if capacity < 1 || capacity > 4096 { | |||
| 39 | + | panic("nefergui: secret buffer capacity must be between 1 and 4096") | |||
| 40 | + | } | |||
| 41 | + | return &SecretBuffer{buf: make([]byte, capacity)} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Len is the number of typed code points (the mask count). | |||
| 45 | + | func (b *SecretBuffer) Len() int { return b.runes } | |||
| 46 | + | ||||
| 47 | + | // Bytes returns the typed bytes. The slice aliases the buffer: do not retain | |||
| 48 | + | // it past the next edit or Wipe, and never convert it to a string. | |||
| 49 | + | func (b *SecretBuffer) Bytes() []byte { return b.buf[:b.n:b.n] } | |||
| 50 | + | ||||
| 51 | + | // Wipe zeroes the buffer. The caller may call it at any time on the owner loop. | |||
| 52 | + | func (b *SecretBuffer) Wipe() { | |||
| 53 | + | clear(b.buf) | |||
| 54 | + | b.n, b.runes = 0, 0 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (b *SecretBuffer) append(p []byte) { | |||
| 58 | + | if len(p) == 0 || len(p) > len(b.buf)-b.n || !utf8.Valid(p) { | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | b.n += copy(b.buf[b.n:], p) | |||
| 62 | + | b.runes += utf8.RuneCount(p) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (b *SecretBuffer) backspace() { | |||
| 66 | + | if b.n == 0 { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | _, size := utf8.DecodeLastRune(b.buf[:b.n]) | |||
| 70 | + | clear(b.buf[b.n-size : b.n]) | |||
| 71 | + | b.n -= size | |||
| 72 | + | b.runes-- | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // LockStatus is an opaque caller-driven state shown by the view, for example | |||
| 76 | + | // while an authentication attempt runs. NeferGUI attaches no meaning to it. | |||
| 77 | + | type LockStatus uint8 | |||
| 78 | + | ||||
| 79 | + | const ( | |||
| 80 | + | LockIdle LockStatus = iota // ready for input | |||
| 81 | + | LockBusy // an attempt is being verified | |||
| 82 | + | LockFailed // the last attempt was rejected | |||
| 83 | + | ) | |||
| 84 | + | ||||
| 85 | + | // LockState is what the lock view may know: never the secret itself. | |||
| 86 | + | type LockState struct { | |||
| 87 | + | Mask int // typed code points, for a mask of that many bullets | |||
| 88 | + | Locked bool // the compositor confirmed the lock (locked event) | |||
| 89 | + | Status LockStatus // latest value received on LockConfig.Status | |||
| 90 | + | Width, Height int // logical size of the surface hosting the view | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Bullets returns Mask bullet characters. | |||
| 94 | + | func (s LockState) Bullets() string { return strings.Repeat("•", s.Mask) } | |||
| 95 | + | ||||
| 96 | + | // LockConfig configures RunLock. | |||
| 97 | + | type LockConfig struct { | |||
| 98 | + | // Display is the Wayland socket name; empty uses the environment. | |||
| 99 | + | Display string | |||
| 100 | + | // Styles is an optional author stylesheet path, as for the Styles option. | |||
| 101 | + | Styles string | |||
| 102 | + | // Secret receives typed input. Required. | |||
| 103 | + | Secret *SecretBuffer | |||
| 104 | + | // View builds the content of the one output that hosts it (the output whose | |||
| 105 | + | // surface has keyboard focus, initially the first). Required. Other outputs | |||
| 106 | + | // show plain opaque black. | |||
| 107 | + | View func(*Frame, LockState) | |||
| 108 | + | // OnLocked runs once on the owner loop when the compositor sends locked. | |||
| 109 | + | OnLocked func() | |||
| 110 | + | // OnSubmit runs on the owner loop when Enter is pressed after locked. It | |||
| 111 | + | // must not block: verification is asynchronous. secret aliases Secret's | |||
| 112 | + | // memory and is valid only during the call; it is wiped right after it | |||
| 113 | + | // returns, so the callee copies or consumes it before returning. | |||
| 114 | + | OnSubmit func(secret []byte) | |||
| 115 | + | // Unlock, when it receives a value, asks RunLock to send | |||
| 116 | + | // unlock_and_destroy, roundtrip and return nil. This is the only unlock | |||
| 117 | + | // path: it is the caller's explicit request after its own verification. A | |||
| 118 | + | // request received before the locked event is held until locked; closing | |||
| 119 | + | // the channel is not a request. A nil channel never unlocks. | |||
| 120 | + | Unlock <-chan struct{} | |||
| 121 | + | // Status delivers the state shown as LockState.Status; each received value | |||
| 122 | + | // schedules a redraw. Optional. | |||
| 123 | + | Status <-chan LockStatus | |||
| 124 | + | // Wake requests a redraw for each received value. | |||
| 125 | + | Wake <-chan struct{} | |||
| 126 | + | // OnOutputError reports an output added after acquisition that could not | |||
| 127 | + | // get a lock surface; the compositor keeps it black. Optional, owner loop. | |||
| 128 | + | OnOutputError func(output string, err error) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | type lockSurface struct { | |||
| 132 | + | w *wayland.Window | |||
| 133 | + | output uint32 | |||
| 134 | + | s *session.Session | |||
| 135 | + | p *session.Pump | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // RunLock acquires ext-session-lock on one connection and shows opaque black on | |||
| 139 | + | // every output through the regular Vulkan/DMA-BUF/explicit-sync path. Outputs | |||
| 140 | + | // added or removed later gain or lose a lock surface. Keyboard input feeds | |||
| 141 | + | // cfg.Secret only through the secret keyboard path. RunLock returns nil after | |||
| 142 | + | // an unlock requested through cfg.Unlock, ErrLockFinished (wrapped) when the | |||
| 143 | + | // compositor ends or refuses the lock, or the first failure; it never unlocks | |||
| 144 | + | // on failure or cancellation (closing the connection keeps the session locked). | |||
| 145 | + | func RunLock(ctx context.Context, cfg LockConfig) (err error) { | |||
| 146 | + | if cfg.Secret == nil || cfg.View == nil { | |||
| 147 | + | return errors.New("nefergui: lock needs Secret and View") | |||
| 148 | + | } | |||
| 149 | + | if err = ctx.Err(); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | defer cfg.Secret.Wipe() // also on panic and every early return | |||
| 153 | + | ctx, stop := context.WithCancel(ctx) | |||
| 154 | + | defer stop() // ends the wake forwarder with RunLock | |||
| 155 | + | r := newRuntime() | |||
| 156 | + | if cfg.Styles != "" { | |||
| 157 | + | data, e := os.ReadFile(cfg.Styles) | |||
| 158 | + | if e != nil { | |||
| 159 | + | return fmt.Errorf("nefergui: styles: %w", e) | |||
| 160 | + | } | |||
| 161 | + | r.styles = css.Compile(css.UA(), css.Parse(string(data))) | |||
| 162 | + | } | |||
| 163 | + | catalog, err := text.Load(text.SystemSource{}) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return fmt.Errorf("nefergui: fonts: %w", err) | |||
| 166 | + | } | |||
| 167 | + | if len(catalog.Faces) == 0 { | |||
| 168 | + | return errors.New("nefergui: no usable fonts") | |||
| 169 | + | } | |||
| 170 | + | r.setTextEngine(text.NewEngine(catalog)) | |||
| 171 | + | conn, err := wayland.ConnectConnection(ctx, cfg.Display) | |||
| 172 | + | if err != nil { | |||
| 173 | + | return fmt.Errorf("nefergui: open platform: %w", err) | |||
| 174 | + | } | |||
| 175 | + | l := &locker{cfg: cfg, r: r, conn: conn, ctx: ctx, surfaces: map[*wayland.Window]*lockSurface{}, pending: map[*wayland.Window]pendingLock{}, covered: map[uint32]bool{}, initial: map[uint32]bool{}, gpuWake: make(chan struct{}, 1), scratch: make([]byte, 128)} | |||
| 176 | + | defer func() { | |||
| 177 | + | l.closeAll() | |||
| 178 | + | clear(l.scratch) | |||
| 179 | + | if l.kb != nil { | |||
| 180 | + | l.kb.Close() | |||
| 181 | + | } | |||
| 182 | + | err = errors.Join(err, conn.Close()) | |||
| 183 | + | }() | |||
| 184 | + | if l.kb, err = keyboard.New(""); err != nil { | |||
| 185 | + | return err | |||
| 186 | + | } | |||
| 187 | + | l.kb.Focus(false) | |||
| 188 | + | if err = conn.AcquireLock(); err != nil { | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | // Surfaces for the outputs present now are created before the reader starts | |||
| 192 | + | // so the compositor can see them while it decides to send locked. | |||
| 193 | + | if err = l.reconcile(true); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | events := conn.StartReader() | |||
| 197 | + | // Lock and output events that arrived while the first surfaces were set up | |||
| 198 | + | // were kept for us: locked must enable unlock, finished must fail the run. | |||
| 199 | + | for _, ev := range conn.TakeHeld() { | |||
| 200 | + | if err = l.handle(ev); err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | if cfg.Wake != nil { | |||
| 205 | + | go r.forwardWake(ctx, cfg.Wake) | |||
| 206 | + | } | |||
| 207 | + | return l.loop(events) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // pendingLock is a created lock surface and the output it covers. | |||
| 211 | + | type pendingLock struct { | |||
| 212 | + | output uint32 | |||
| 213 | + | name string // connector name, for error reports | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | type locker struct { | |||
| 217 | + | cfg LockConfig | |||
| 218 | + | r *runtime | |||
| 219 | + | conn *wayland.Connection | |||
| 220 | + | ctx context.Context | |||
| 221 | + | kb *keyboard.Keyboard | |||
| 222 | + | scratch []byte | |||
| 223 | + | surfaces map[*wayland.Window]*lockSurface | |||
| 224 | + | pending map[*wayland.Window]pendingLock // created, not yet configured with feedback | |||
| 225 | + | covered map[uint32]bool // output globals with a surface or a pending one | |||
| 226 | + | initial map[uint32]bool // outputs present at acquisition: their failures abort | |||
| 227 | + | host *lockSurface | |||
| 228 | + | locked bool | |||
| 229 | + | status LockStatus | |||
| 230 | + | unlock bool // the caller asked for unlock; honoured once locked | |||
| 231 | + | gpuWake chan struct{} | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (l *locker) loop(events <-chan wayland.Event) error { | |||
| 235 | + | for { | |||
| 236 | + | if err := l.promote(); err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | retry := false | |||
| 240 | + | for _, ls := range l.surfaces { | |||
| 241 | + | again, err := ls.p.Step() | |||
| 242 | + | if err != nil { | |||
| 243 | + | return err | |||
| 244 | + | } | |||
| 245 | + | retry = retry || again | |||
| 246 | + | } | |||
| 247 | + | var retryC, repeatC <-chan time.Time | |||
| 248 | + | if retry { | |||
| 249 | + | retryC = time.After(5 * time.Millisecond) | |||
| 250 | + | } | |||
| 251 | + | if at, ok := l.kb.NextRepeat(); ok { | |||
| 252 | + | repeatC = time.After(time.Until(at)) | |||
| 253 | + | } | |||
| 254 | + | select { | |||
| 255 | + | case <-l.ctx.Done(): | |||
| 256 | + | return l.ctx.Err() | |||
| 257 | + | case <-l.r.wake: | |||
| 258 | + | l.invalidateHost() | |||
| 259 | + | case _, ok := <-l.cfg.Unlock: | |||
| 260 | + | if ok { | |||
| 261 | + | l.unlock = true | |||
| 262 | + | } else { | |||
| 263 | + | l.cfg.Unlock = nil // closing is not a request | |||
| 264 | + | } | |||
| 265 | + | case st, ok := <-l.cfg.Status: | |||
| 266 | + | if ok { | |||
| 267 | + | l.status = st | |||
| 268 | + | l.secretChanged() | |||
| 269 | + | } else { | |||
| 270 | + | l.cfg.Status = nil | |||
| 271 | + | } | |||
| 272 | + | case <-l.gpuWake: | |||
| 273 | + | case <-retryC: | |||
| 274 | + | case <-repeatC: | |||
| 275 | + | if _, err := l.kb.TickSecret(time.Now(), l.scratch, l.consume); err != nil { | |||
| 276 | + | return err | |||
| 277 | + | } | |||
| 278 | + | l.secretChanged() | |||
| 279 | + | case ev := <-events: | |||
| 280 | + | if err := l.handle(ev); err != nil { | |||
| 281 | + | return err | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | if l.shouldUnlock() { | |||
| 285 | + | return l.finishUnlock() | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func (l *locker) handle(ev wayland.Event) error { | |||
| 291 | + | switch ev.Kind { | |||
| 292 | + | case wayland.BufferRelease: | |||
| 293 | + | if ls := l.surfaces[ev.Window]; ls != nil { | |||
| 294 | + | return ls.p.BufferReleased(ev.BufferID) | |||
| 295 | + | } | |||
| 296 | + | return nil | |||
| 297 | + | case wayland.OutputRemoved: | |||
| 298 | + | l.removeOutput(ev.Output.Global) | |||
| 299 | + | } | |||
| 300 | + | if err := l.conn.Apply(ev); err != nil { | |||
| 301 | + | return err | |||
| 302 | + | } | |||
| 303 | + | switch ev.Kind { | |||
| 304 | + | case wayland.LockAcquired: | |||
| 305 | + | l.locked = true | |||
| 306 | + | l.invalidateHost() | |||
| 307 | + | if l.cfg.OnLocked != nil { | |||
| 308 | + | l.cfg.OnLocked() | |||
| 309 | + | } | |||
| 310 | + | case wayland.LockFinished: | |||
| 311 | + | return ErrLockFinished | |||
| 312 | + | case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded: | |||
| 313 | + | if err := l.reconcile(false); err != nil { | |||
| 314 | + | return err | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | return l.drainInput() | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // reconcile gives every named output without one a lock surface. A failure on | |||
| 321 | + | // an initial output aborts the lock. A hotplugged output that cannot get a | |||
| 322 | + | // surface is logged and left alone: the compositor keeps it black regardless. | |||
| 323 | + | func (l *locker) reconcile(initial bool) error { | |||
| 324 | + | for _, o := range l.conn.Outputs() { | |||
| 325 | + | if l.covered[o.Global] { | |||
| 326 | + | continue | |||
| 327 | + | } | |||
| 328 | + | l.covered[o.Global] = true // also on failure: never retried for this lifetime | |||
| 329 | + | w, err := l.conn.NewWindow(l.ctx, 1, 1, false, wayland.SurfaceOptions{Lock: &wayland.LockOptions{Output: o.Global}}) | |||
| 330 | + | if err != nil { | |||
| 331 | + | if err = l.outputFailure(initial, "lock surface", o.Name, err); err != nil { | |||
| 332 | + | return err | |||
| 333 | + | } | |||
| 334 | + | continue | |||
| 335 | + | } | |||
| 336 | + | if initial { | |||
| 337 | + | l.initial[o.Global] = true | |||
| 338 | + | } | |||
| 339 | + | l.pending[w] = pendingLock{output: o.Global, name: o.Name} | |||
| 340 | + | } | |||
| 341 | + | return nil | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | // promote opens the Vulkan session of every window whose configure was | |||
| 345 | + | // acknowledged and whose dmabuf feedback round is complete, in ascending | |||
| 346 | + | // output global order so the first host is deterministic. | |||
| 347 | + | func (l *locker) promote() error { | |||
| 348 | + | ready := make([]*wayland.Window, 0, len(l.pending)) | |||
| 349 | + | for w := range l.pending { | |||
| 350 | + | if w.Configured && w.FeedbackDone { | |||
| 351 | + | ready = append(ready, w) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | slices.SortFunc(ready, func(a, b *wayland.Window) int { return cmp.Compare(l.pending[a].output, l.pending[b].output) }) | |||
| 355 | + | for _, w := range ready { | |||
| 356 | + | pl := l.pending[w] | |||
| 357 | + | output := pl.output | |||
| 358 | + | delete(l.pending, w) | |||
| 359 | + | ls, err := l.open(w, output) // closes w on failure | |||
| 360 | + | if err != nil { | |||
| 361 | + | if err = l.outputFailure(l.initial[output], "lock render path", pl.name, err); err != nil { | |||
| 362 | + | return err | |||
| 363 | + | } | |||
| 364 | + | continue | |||
| 365 | + | } | |||
| 366 | + | l.surfaces[w] = ls | |||
| 367 | + | if l.host == nil { | |||
| 368 | + | l.setHost(ls) | |||
| 369 | + | } | |||
| 370 | + | } | |||
| 371 | + | return nil | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | // outputFailure applies the per-output failure policy. A refused or ended lock | |||
| 375 | + | // always aborts (as ErrLockFinished). Otherwise only outputs present at | |||
| 376 | + | // acquisition abort; a hotplugged output is skipped, since the | |||
| 377 | + | // compositor keeps it black without our surface; OnOutputError reports it. | |||
| 378 | + | func (l *locker) outputFailure(initial bool, what, output string, err error) error { | |||
| 379 | + | if abort := outputFailure(initial, what, output, err); abort != nil { | |||
| 380 | + | return abort | |||
| 381 | + | } | |||
| 382 | + | if l.cfg.OnOutputError != nil { | |||
| 383 | + | l.cfg.OnOutputError(output, fmt.Errorf("%s: %w", what, err)) | |||
| 384 | + | } | |||
| 385 | + | return nil | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func outputFailure(initial bool, what, output string, err error) error { | |||
| 389 | + | if errors.Is(err, wayland.ErrLockFinished) { | |||
| 390 | + | return fmt.Errorf("%w: %w", ErrLockFinished, err) | |||
| 391 | + | } | |||
| 392 | + | if initial { | |||
| 393 | + | return fmt.Errorf("nefergui: %s for output %s: %w", what, output, err) | |||
| 394 | + | } | |||
| 395 | + | return nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | // open builds the Vulkan session and frame pump of a configured lock window. | |||
| 399 | + | func (l *locker) open(w *wayland.Window, output uint32) (*lockSurface, error) { | |||
| 400 | + | s, err := session.OpenWindow(w, false) | |||
| 401 | + | if err != nil { | |||
| 402 | + | return nil, err | |||
| 403 | + | } | |||
| 404 | + | ls := &lockSurface{w: w, output: output, s: s} | |||
| 405 | + | if ls.p, err = s.NewPump(l.ctx, l.gpuWake, func() (render.Frame, bool, error) { return l.draw(ls) }); err != nil { | |||
| 406 | + | _ = s.Close() | |||
| 407 | + | return nil, err | |||
| 408 | + | } | |||
| 409 | + | return ls, nil | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func (l *locker) removeOutput(global uint32) { | |||
| 413 | + | delete(l.covered, global) | |||
| 414 | + | for w, pl := range l.pending { | |||
| 415 | + | if pl.output == global { | |||
| 416 | + | delete(l.pending, w) | |||
| 417 | + | _ = w.Close() | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | for w, ls := range l.surfaces { | |||
| 421 | + | if ls.output != global { | |||
| 422 | + | continue | |||
| 423 | + | } | |||
| 424 | + | delete(l.surfaces, w) | |||
| 425 | + | ls.close() | |||
| 426 | + | if l.host == ls { | |||
| 427 | + | l.host = nil | |||
| 428 | + | var next *lockSurface | |||
| 429 | + | for _, o := range l.surfaces { | |||
| 430 | + | if next == nil || o.output < next.output { | |||
| 431 | + | next = o | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | if next != nil { | |||
| 435 | + | l.setHost(next) | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func (ls *lockSurface) close() { | |||
| 442 | + | ls.p.Close() | |||
| 443 | + | _ = ls.s.Close() | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func (l *locker) closeAll() { | |||
| 447 | + | for w, ls := range l.surfaces { | |||
| 448 | + | delete(l.surfaces, w) | |||
| 449 | + | ls.close() | |||
| 450 | + | } | |||
| 451 | + | for w := range l.pending { | |||
| 452 | + | delete(l.pending, w) | |||
| 453 | + | _ = w.Close() | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | func (l *locker) setHost(ls *lockSurface) { | |||
| 458 | + | if l.host == ls { | |||
| 459 | + | return | |||
| 460 | + | } | |||
| 461 | + | old := l.host | |||
| 462 | + | l.host = ls | |||
| 463 | + | if old != nil { | |||
| 464 | + | old.p.Invalidate() | |||
| 465 | + | } | |||
| 466 | + | l.r.Redraw() | |||
| 467 | + | ls.p.Invalidate() | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func (l *locker) invalidateHost() { | |||
| 471 | + | if l.host != nil { | |||
| 472 | + | l.host.p.Invalidate() | |||
| 473 | + | } | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // draw shows the view on the host output and plain black elsewhere. An empty | |||
| 477 | + | // frame is the opaque clear color; alpha is not part of the XRGB buffer. | |||
| 478 | + | func (l *locker) draw(ls *lockSurface) (render.Frame, bool, error) { | |||
| 479 | + | if l.host != ls { | |||
| 480 | + | return render.Frame{}, true, nil | |||
| 481 | + | } | |||
| 482 | + | w := ls.w | |||
| 483 | + | l.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale}) | |||
| 484 | + | state := LockState{Mask: l.cfg.Secret.Len(), Locked: l.locked, Status: l.status, Width: int(w.Width), Height: int(w.Height)} | |||
| 485 | + | if !l.r.Build(func(f *Frame) { l.cfg.View(f, state) }) { | |||
| 486 | + | return render.Frame{}, false, nil | |||
| 487 | + | } | |||
| 488 | + | pw, ph, err := w.PhysicalSize() | |||
| 489 | + | if err != nil { | |||
| 490 | + | return render.Frame{}, false, err | |||
| 491 | + | } | |||
| 492 | + | frame, err := ls.s.Preparer.Prepare(l.r.output.Display, w.Scale, int(pw), int(ph)) | |||
| 493 | + | return frame, true, err | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // drainInput feeds raw keyboard protocol events from every lock window to the | |||
| 497 | + | // secret keyboard. Nothing here creates a string or reaches the UI runtime. | |||
| 498 | + | func (l *locker) drainInput() error { | |||
| 499 | + | take := func(w *wayland.Window) error { | |||
| 500 | + | events := w.TakeInputEvents() | |||
| 501 | + | for i, e := range events { | |||
| 502 | + | if err := l.input(w, e); err != nil { | |||
| 503 | + | for _, rest := range events[i+1:] { | |||
| 504 | + | wayland.CloseEventFD(rest) // never leak keymap FDs | |||
| 505 | + | } | |||
| 506 | + | return err | |||
| 507 | + | } | |||
| 508 | + | } | |||
| 509 | + | return nil | |||
| 510 | + | } | |||
| 511 | + | for w := range l.surfaces { | |||
| 512 | + | if err := take(w); err != nil { | |||
| 513 | + | return err | |||
| 514 | + | } | |||
| 515 | + | } | |||
| 516 | + | for w := range l.pending { | |||
| 517 | + | if err := take(w); err != nil { | |||
| 518 | + | return err | |||
| 519 | + | } | |||
| 520 | + | } | |||
| 521 | + | return nil | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | func (l *locker) input(w *wayland.Window, e wayland.Event) error { | |||
| 525 | + | switch e.Kind { | |||
| 526 | + | case wayland.InputKeymap: | |||
| 527 | + | if e.FD < 0 { | |||
| 528 | + | return errors.New("nefergui: missing keymap fd") | |||
| 529 | + | } | |||
| 530 | + | if e.Format != 1 || e.Size == 0 || e.Size > 16<<20 { | |||
| 531 | + | wayland.CloseEventFD(e) | |||
| 532 | + | return nil | |||
| 533 | + | } | |||
| 534 | + | return l.kb.ReplaceFD(e.FD, int(e.Size)) | |||
| 535 | + | case wayland.InputModifiers: | |||
| 536 | + | _ = l.kb.Mask(e.Mods[0], e.Mods[1], e.Mods[2], 0, 0, e.Mods[3]) // no keymap yet: ignore | |||
| 537 | + | case wayland.InputRepeatInfo: | |||
| 538 | + | l.kb.RepeatInfo(int(e.Rate), time.Duration(e.Delay)*time.Millisecond) | |||
| 539 | + | case wayland.InputFocusIn: | |||
| 540 | + | l.kb.Focus(true) | |||
| 541 | + | if ls := l.surfaces[w]; ls != nil { | |||
| 542 | + | l.setHost(ls) | |||
| 543 | + | } | |||
| 544 | + | case wayland.InputFocusOut: | |||
| 545 | + | l.kb.ClearSecret() | |||
| 546 | + | l.kb.Focus(false) | |||
| 547 | + | case wayland.InputKey: | |||
| 548 | + | if err := l.kb.EventSecret(e.Code, e.Pressed, time.Now(), l.scratch, l.consume); err != nil { | |||
| 549 | + | return err | |||
| 550 | + | } | |||
| 551 | + | l.secretChanged() | |||
| 552 | + | } | |||
| 553 | + | return nil | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | func (l *locker) secretChanged() { | |||
| 557 | + | l.r.Redraw() | |||
| 558 | + | l.invalidateHost() | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | // consume applies one secret key: bytes append, Backspace removes a code | |||
| 562 | + | // point, Escape and Ctrl+U clear, Enter submits. | |||
| 563 | + | func (l *locker) consume(k keyboard.SecretKey, chunk []byte) { | |||
| 564 | + | if !k.Pressed { | |||
| 565 | + | return | |||
| 566 | + | } | |||
| 567 | + | b := l.cfg.Secret | |||
| 568 | + | switch k.Kind { | |||
| 569 | + | case keyboard.SecretBytes: | |||
| 570 | + | b.append(chunk) | |||
| 571 | + | case keyboard.SecretBackspace: | |||
| 572 | + | b.backspace() | |||
| 573 | + | case keyboard.SecretEscape, keyboard.SecretClear: | |||
| 574 | + | b.Wipe() | |||
| 575 | + | case keyboard.SecretReturn: | |||
| 576 | + | // Before the locked event Enter only clears. Submitting never unlocks. | |||
| 577 | + | defer b.Wipe() // even if OnSubmit panics | |||
| 578 | + | if l.locked && l.cfg.OnSubmit != nil && b.Len() > 0 { | |||
| 579 | + | l.cfg.OnSubmit(b.Bytes()) | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // shouldUnlock reports that the caller asked for unlock and the lock is held. | |||
| 585 | + | func (l *locker) shouldUnlock() bool { return l.unlock && l.locked } | |||
| 586 | + | ||||
| 587 | + | // finishUnlock is reached only after the caller sent on cfg.Unlock. | |||
| 588 | + | func (l *locker) finishUnlock() error { | |||
| 589 | + | if err := l.conn.UnlockAndDestroy(); err != nil { // includes the display roundtrip | |||
| 590 | + | return err | |||
| 591 | + | } | |||
| 592 | + | l.closeAll() | |||
| 593 | + | return nil | |||
| 594 | + | } | |||
A internal/ui/lock_test.go +281 -0
| @@ -0,0 +1,281 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package ui | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "os" | |
| 11 | + | "path/filepath" | |
| 12 | + | "regexp" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/nefergui/internal/harness" | |
| 18 | + | "github.com/bnema/nefergui/internal/keyboard" | |
| 19 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func TestSecretBufferEditing(t *testing.T) { | |
| 23 | + | b := NewSecretBuffer(8) | |
| 24 | + | b.append([]byte("a")) | |
| 25 | + | b.append([]byte("é")) // two bytes, one code point | |
| 26 | + | b.append([]byte("z")) | |
| 27 | + | if b.Len() != 3 || string(b.Bytes()) != "aéz" { | |
| 28 | + | t.Fatalf("len=%d", b.Len()) | |
| 29 | + | } | |
| 30 | + | b.append([]byte{0xff}) // invalid UTF-8 is dropped | |
| 31 | + | b.append(bytes.Repeat([]byte("x"), 9)) | |
| 32 | + | if b.Len() != 3 { | |
| 33 | + | t.Fatal("invalid or oversized chunk accepted") | |
| 34 | + | } | |
| 35 | + | b.backspace() | |
| 36 | + | b.backspace() // removes the two-byte code point whole | |
| 37 | + | if b.Len() != 1 || !bytes.Equal(b.Bytes(), []byte("a")) { | |
| 38 | + | t.Fatalf("backspace: len=%d", b.Len()) | |
| 39 | + | } | |
| 40 | + | backing := b.buf | |
| 41 | + | b.Wipe() | |
| 42 | + | if b.Len() != 0 || len(b.Bytes()) != 0 || !bytes.Equal(backing, make([]byte, 8)) { | |
| 43 | + | t.Fatal("buffer not wiped") | |
| 44 | + | } | |
| 45 | + | if got := (LockState{Mask: 3}).Bullets(); got != "•••" { | |
| 46 | + | t.Fatal(got) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestConsumeSubmitIsAsyncAndNeverUnlocks(t *testing.T) { | |
| 51 | + | var got [][]byte | |
| 52 | + | secret := NewSecretBuffer(8) | |
| 53 | + | l := &locker{cfg: LockConfig{Secret: secret, OnSubmit: func(b []byte) { got = append(got, bytes.Clone(b)) }}} | |
| 54 | + | enter := keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true} | |
| 55 | + | secret.append([]byte("pw")) | |
| 56 | + | l.consume(enter, nil) // before locked: only clears | |
| 57 | + | if len(got) != 0 || secret.Len() != 0 || l.unlock { | |
| 58 | + | t.Fatalf("submit before locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock) | |
| 59 | + | } | |
| 60 | + | l.locked = true | |
| 61 | + | secret.append([]byte("pw")) | |
| 62 | + | l.consume(enter, nil) | |
| 63 | + | if len(got) != 1 || string(got[0]) != "pw" || secret.Len() != 0 || l.unlock { | |
| 64 | + | t.Fatalf("submit after locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock) | |
| 65 | + | } | |
| 66 | + | l.consume(enter, nil) // empty buffer submits nothing | |
| 67 | + | if len(got) != 1 { | |
| 68 | + | t.Fatal("empty submit reached the callback") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestShouldUnlockGate(t *testing.T) { | |
| 73 | + | l := &locker{conn: &wayland.Connection{}} | |
| 74 | + | if l.shouldUnlock() { | |
| 75 | + | t.Fatal("unlock without request") | |
| 76 | + | } | |
| 77 | + | l.unlock = true // a request held before locked | |
| 78 | + | if l.shouldUnlock() { | |
| 79 | + | t.Fatal("unlock before locked") | |
| 80 | + | } | |
| 81 | + | if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | if !l.shouldUnlock() { | |
| 85 | + | t.Fatal("held request not honoured once locked") | |
| 86 | + | } | |
| 87 | + | l.unlock = false | |
| 88 | + | if l.shouldUnlock() { | |
| 89 | + | t.Fatal("unlock without request after locked") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestConsumeWipesWhenOnSubmitPanics(t *testing.T) { | |
| 94 | + | secret := NewSecretBuffer(8) | |
| 95 | + | l := &locker{locked: true, cfg: LockConfig{Secret: secret, OnSubmit: func([]byte) { panic("boom") }}} | |
| 96 | + | secret.append([]byte("pw")) | |
| 97 | + | func() { | |
| 98 | + | defer func() { _ = recover() }() | |
| 99 | + | l.consume(keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}, nil) | |
| 100 | + | }() | |
| 101 | + | if secret.Len() != 0 || !bytes.Equal(secret.buf, make([]byte, 8)) { | |
| 102 | + | t.Fatal("secret not wiped after OnSubmit panic") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestForwardWakeStopsWithContext(t *testing.T) { | |
| 107 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 108 | + | done := make(chan struct{}) | |
| 109 | + | go func() { newRuntime().forwardWake(ctx, make(chan struct{})); close(done) }() | |
| 110 | + | cancel() | |
| 111 | + | select { | |
| 112 | + | case <-done: | |
| 113 | + | case <-time.After(5 * time.Second): | |
| 114 | + | t.Fatal("forwardWake outlived its context") | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestHarnessRunLock(t *testing.T) { | |
| 119 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 120 | + | t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness") | |
| 121 | + | } | |
| 122 | + | root := t.TempDir() | |
| 123 | + | script := filepath.Join(root, "input.txt") | |
| 124 | + | // Enough time for lock + first frame; then a wrong secret, clear, and the right one. | |
| 125 | + | if err := os.WriteFile(script, []byte("sleep 4s\ntype wrong\nkey Escape\nsleep 200ms\ntype open\nkey Return\nsleep 1s\n"), 0600); err != nil { | |
| 126 | + | t.Fatal(err) | |
| 127 | + | } | |
| 128 | + | result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(root, "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockClient$", "-test.v"}}) | |
| 129 | + | out, _ := os.ReadFile(result.Artifacts["client_stdout"]) | |
| 130 | + | if code != harness.Pass { | |
| 131 | + | errOut, _ := os.ReadFile(result.Artifacts["client_stderr"]) | |
| 132 | + | t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut) | |
| 133 | + | } | |
| 134 | + | t.Logf("client output:\n%s", out) | |
| 135 | + | // Frames coalesce, so only the shape is asserted: the mask rises, Escape | |
| 136 | + | // clears it, and the accepted secret is wiped. | |
| 137 | + | for _, want := range []string{"LOCK locked", "LOCK frame", "LOCK submit ok", "LOCK unlocked [0 ", "LOCK wake stopped"} { | |
| 138 | + | if !strings.Contains(string(out), want) { | |
| 139 | + | t.Fatalf("missing %q in:\n%s", want, out) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | // A mask above zero was drawn before the final wipe to zero. | |
| 143 | + | if !regexp.MustCompile(`LOCK unlocked \[0( [0-9]+)* [1-9][0-9]* 0\]`).Match(out) { | |
| 144 | + | t.Fatalf("no mask above 0 before the final 0 in:\n%s", out) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestHarnessRunLockClient(t *testing.T) { | |
| 149 | + | if os.Getenv("NEFERGUI_DEBUG_DIR") == "" { | |
| 150 | + | t.Skip("harness child only") | |
| 151 | + | } | |
| 152 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 153 | + | defer cancel() | |
| 154 | + | secret := NewSecretBuffer(64) | |
| 155 | + | unlock, status := make(chan struct{}, 1), make(chan LockStatus, 4) | |
| 156 | + | frames := 0 | |
| 157 | + | var maskSeen []int | |
| 158 | + | wake := make(chan struct{}) | |
| 159 | + | err := RunLock(ctx, LockConfig{ | |
| 160 | + | Secret: secret, | |
| 161 | + | Wake: wake, | |
| 162 | + | View: func(f *Frame, s LockState) { | |
| 163 | + | if frames == 0 { | |
| 164 | + | fmt.Println("LOCK frame") | |
| 165 | + | } | |
| 166 | + | frames++ | |
| 167 | + | if len(maskSeen) == 0 || maskSeen[len(maskSeen)-1] != s.Mask { | |
| 168 | + | maskSeen = append(maskSeen, s.Mask) | |
| 169 | + | } | |
| 170 | + | col := f.Root().Column() | |
| 171 | + | col.Text(s.Bullets(), Key("mask")) | |
| 172 | + | }, | |
| 173 | + | OnLocked: func() { fmt.Println("LOCK locked") }, | |
| 174 | + | Unlock: unlock, | |
| 175 | + | Status: status, | |
| 176 | + | OnSubmit: func(b []byte) { | |
| 177 | + | // Test-only comparison; real callers hand the bytes to a verifier. | |
| 178 | + | if string(b) == "open" { | |
| 179 | + | fmt.Println("LOCK submit ok") | |
| 180 | + | status <- LockBusy | |
| 181 | + | go func() { time.Sleep(300 * time.Millisecond); unlock <- struct{}{} }() | |
| 182 | + | } | |
| 183 | + | }, | |
| 184 | + | }) | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | if secret.Len() != 0 { | |
| 189 | + | t.Fatal("secret not wiped after submit") | |
| 190 | + | } | |
| 191 | + | select { | |
| 192 | + | case wake <- struct{}{}: | |
| 193 | + | t.Fatal("wake forwarder outlived RunLock") | |
| 194 | + | case <-time.After(200 * time.Millisecond): | |
| 195 | + | fmt.Println("LOCK wake stopped") | |
| 196 | + | } | |
| 197 | + | fmt.Println("LOCK unlocked", maskSeen) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestOutputFailurePolicy(t *testing.T) { | |
| 201 | + | boom := errors.New("boom") | |
| 202 | + | if err := outputFailure(true, "lock surface", "DP-1", boom); !errors.Is(err, boom) { | |
| 203 | + | t.Fatalf("initial output failure must abort: %v", err) | |
| 204 | + | } | |
| 205 | + | if err := outputFailure(false, "lock surface", "DP-2", boom); err != nil { | |
| 206 | + | t.Fatalf("hotplug failure must be skipped: %v", err) | |
| 207 | + | } | |
| 208 | + | var reported []string | |
| 209 | + | l := &locker{cfg: LockConfig{OnOutputError: func(o string, err error) { | |
| 210 | + | if errors.Is(err, boom) { | |
| 211 | + | reported = append(reported, o) | |
| 212 | + | } | |
| 213 | + | }}} | |
| 214 | + | if err := l.outputFailure(false, "lock surface", "DP-4", boom); err != nil || len(reported) != 1 || reported[0] != "DP-4" { | |
| 215 | + | t.Fatalf("hotplug failure must be reported and skipped: %v %v", err, reported) | |
| 216 | + | } | |
| 217 | + | if err := l.outputFailure(true, "lock surface", "DP-5", boom); err == nil || len(reported) != 1 { | |
| 218 | + | t.Fatalf("initial failure must abort without a skip report: %v %v", err, reported) | |
| 219 | + | } | |
| 220 | + | for _, initial := range []bool{true, false} { | |
| 221 | + | err := outputFailure(initial, "lock surface", "DP-3", fmt.Errorf("x: %w", wayland.ErrLockFinished)) | |
| 222 | + | if !errors.Is(err, ErrLockFinished) || !errors.Is(err, wayland.ErrLockFinished) { | |
| 223 | + | t.Fatalf("finished (initial=%v) must map to ErrLockFinished: %v", initial, err) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // Events kept during setup reach handle() exactly as reader events do. | |
| 229 | + | func TestHeldLockEventsDriveLocker(t *testing.T) { | |
| 230 | + | onLocked := 0 | |
| 231 | + | l := &locker{conn: &wayland.Connection{}, cfg: LockConfig{OnLocked: func() { onLocked++ }}} | |
| 232 | + | if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil || !l.locked || onLocked != 1 { | |
| 233 | + | t.Fatalf("locked lost: err=%v locked=%v calls=%d", err, l.locked, onLocked) | |
| 234 | + | } | |
| 235 | + | l.unlock = true | |
| 236 | + | if !l.shouldUnlock() { | |
| 237 | + | t.Fatal("unlock not possible after a held locked") | |
| 238 | + | } | |
| 239 | + | if err := l.handle(wayland.Event{Kind: wayland.LockFinished}); !errors.Is(err, ErrLockFinished) { | |
| 240 | + | t.Fatalf("finished: %v", err) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // A second locker is refused by the compositor with `finished`, which must | |
| 245 | + | // surface as ErrLockFinished however early it arrives during setup. | |
| 246 | + | func TestHarnessRunLockRefused(t *testing.T) { | |
| 247 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |
| 248 | + | t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness") | |
| 249 | + | } | |
| 250 | + | result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(t.TempDir(), "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockRefusedClient$", "-test.v"}}) | |
| 251 | + | out, _ := os.ReadFile(result.Artifacts["client_stdout"]) | |
| 252 | + | // A protected compositor withholds debug PNGs by design, so the harness may | |
| 253 | + | // report a missing frame; this test only needs the client's refusal result. | |
| 254 | + | noFrame := code == harness.ProcessFailure && strings.HasPrefix(result.Error, "no frame") | |
| 255 | + | if (code != harness.Pass && !noFrame) || !strings.Contains(string(out), "LOCK refused ok") { | |
| 256 | + | errOut, _ := os.ReadFile(result.Artifacts["client_stderr"]) | |
| 257 | + | t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestHarnessRunLockRefusedClient(t *testing.T) { | |
| 262 | + | if os.Getenv("NEFERGUI_DEBUG_DIR") == "" { | |
| 263 | + | t.Skip("harness child only") | |
| 264 | + | } | |
| 265 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 266 | + | defer cancel() | |
| 267 | + | first, err := wayland.ConnectConnection(ctx, "") | |
| 268 | + | if err != nil { | |
| 269 | + | t.Fatal(err) | |
| 270 | + | } | |
| 271 | + | defer first.Close() | |
| 272 | + | if err = first.AcquireLock(); err != nil { | |
| 273 | + | t.Fatal(err) | |
| 274 | + | } | |
| 275 | + | // The first owner stays alive and unanswered; the compositor refuses the next. | |
| 276 | + | err = RunLock(ctx, LockConfig{Secret: NewSecretBuffer(8), View: func(*Frame, LockState) {}}) | |
| 277 | + | if !errors.Is(err, ErrLockFinished) { | |
| 278 | + | t.Fatalf("refused lock: %v", err) | |
| 279 | + | } | |
| 280 | + | fmt.Println("LOCK refused ok") | |
| 281 | + | } | |
| @@ -0,0 +1,281 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package ui | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "os" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "regexp" | |||
| 13 | + | "strings" | |||
| 14 | + | "testing" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/nefergui/internal/harness" | |||
| 18 | + | "github.com/bnema/nefergui/internal/keyboard" | |||
| 19 | + | "github.com/bnema/nefergui/internal/platform/wayland" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | func TestSecretBufferEditing(t *testing.T) { | |||
| 23 | + | b := NewSecretBuffer(8) | |||
| 24 | + | b.append([]byte("a")) | |||
| 25 | + | b.append([]byte("é")) // two bytes, one code point | |||
| 26 | + | b.append([]byte("z")) | |||
| 27 | + | if b.Len() != 3 || string(b.Bytes()) != "aéz" { | |||
| 28 | + | t.Fatalf("len=%d", b.Len()) | |||
| 29 | + | } | |||
| 30 | + | b.append([]byte{0xff}) // invalid UTF-8 is dropped | |||
| 31 | + | b.append(bytes.Repeat([]byte("x"), 9)) | |||
| 32 | + | if b.Len() != 3 { | |||
| 33 | + | t.Fatal("invalid or oversized chunk accepted") | |||
| 34 | + | } | |||
| 35 | + | b.backspace() | |||
| 36 | + | b.backspace() // removes the two-byte code point whole | |||
| 37 | + | if b.Len() != 1 || !bytes.Equal(b.Bytes(), []byte("a")) { | |||
| 38 | + | t.Fatalf("backspace: len=%d", b.Len()) | |||
| 39 | + | } | |||
| 40 | + | backing := b.buf | |||
| 41 | + | b.Wipe() | |||
| 42 | + | if b.Len() != 0 || len(b.Bytes()) != 0 || !bytes.Equal(backing, make([]byte, 8)) { | |||
| 43 | + | t.Fatal("buffer not wiped") | |||
| 44 | + | } | |||
| 45 | + | if got := (LockState{Mask: 3}).Bullets(); got != "•••" { | |||
| 46 | + | t.Fatal(got) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestConsumeSubmitIsAsyncAndNeverUnlocks(t *testing.T) { | |||
| 51 | + | var got [][]byte | |||
| 52 | + | secret := NewSecretBuffer(8) | |||
| 53 | + | l := &locker{cfg: LockConfig{Secret: secret, OnSubmit: func(b []byte) { got = append(got, bytes.Clone(b)) }}} | |||
| 54 | + | enter := keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true} | |||
| 55 | + | secret.append([]byte("pw")) | |||
| 56 | + | l.consume(enter, nil) // before locked: only clears | |||
| 57 | + | if len(got) != 0 || secret.Len() != 0 || l.unlock { | |||
| 58 | + | t.Fatalf("submit before locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock) | |||
| 59 | + | } | |||
| 60 | + | l.locked = true | |||
| 61 | + | secret.append([]byte("pw")) | |||
| 62 | + | l.consume(enter, nil) | |||
| 63 | + | if len(got) != 1 || string(got[0]) != "pw" || secret.Len() != 0 || l.unlock { | |||
| 64 | + | t.Fatalf("submit after locked: got=%d len=%d unlock=%v", len(got), secret.Len(), l.unlock) | |||
| 65 | + | } | |||
| 66 | + | l.consume(enter, nil) // empty buffer submits nothing | |||
| 67 | + | if len(got) != 1 { | |||
| 68 | + | t.Fatal("empty submit reached the callback") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestShouldUnlockGate(t *testing.T) { | |||
| 73 | + | l := &locker{conn: &wayland.Connection{}} | |||
| 74 | + | if l.shouldUnlock() { | |||
| 75 | + | t.Fatal("unlock without request") | |||
| 76 | + | } | |||
| 77 | + | l.unlock = true // a request held before locked | |||
| 78 | + | if l.shouldUnlock() { | |||
| 79 | + | t.Fatal("unlock before locked") | |||
| 80 | + | } | |||
| 81 | + | if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | if !l.shouldUnlock() { | |||
| 85 | + | t.Fatal("held request not honoured once locked") | |||
| 86 | + | } | |||
| 87 | + | l.unlock = false | |||
| 88 | + | if l.shouldUnlock() { | |||
| 89 | + | t.Fatal("unlock without request after locked") | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestConsumeWipesWhenOnSubmitPanics(t *testing.T) { | |||
| 94 | + | secret := NewSecretBuffer(8) | |||
| 95 | + | l := &locker{locked: true, cfg: LockConfig{Secret: secret, OnSubmit: func([]byte) { panic("boom") }}} | |||
| 96 | + | secret.append([]byte("pw")) | |||
| 97 | + | func() { | |||
| 98 | + | defer func() { _ = recover() }() | |||
| 99 | + | l.consume(keyboard.SecretKey{Kind: keyboard.SecretReturn, Pressed: true}, nil) | |||
| 100 | + | }() | |||
| 101 | + | if secret.Len() != 0 || !bytes.Equal(secret.buf, make([]byte, 8)) { | |||
| 102 | + | t.Fatal("secret not wiped after OnSubmit panic") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestForwardWakeStopsWithContext(t *testing.T) { | |||
| 107 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 108 | + | done := make(chan struct{}) | |||
| 109 | + | go func() { newRuntime().forwardWake(ctx, make(chan struct{})); close(done) }() | |||
| 110 | + | cancel() | |||
| 111 | + | select { | |||
| 112 | + | case <-done: | |||
| 113 | + | case <-time.After(5 * time.Second): | |||
| 114 | + | t.Fatal("forwardWake outlived its context") | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestHarnessRunLock(t *testing.T) { | |||
| 119 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |||
| 120 | + | t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness") | |||
| 121 | + | } | |||
| 122 | + | root := t.TempDir() | |||
| 123 | + | script := filepath.Join(root, "input.txt") | |||
| 124 | + | // Enough time for lock + first frame; then a wrong secret, clear, and the right one. | |||
| 125 | + | if err := os.WriteFile(script, []byte("sleep 4s\ntype wrong\nkey Escape\nsleep 200ms\ntype open\nkey Return\nsleep 1s\n"), 0600); err != nil { | |||
| 126 | + | t.Fatal(err) | |||
| 127 | + | } | |||
| 128 | + | result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(root, "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Input: script, Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockClient$", "-test.v"}}) | |||
| 129 | + | out, _ := os.ReadFile(result.Artifacts["client_stdout"]) | |||
| 130 | + | if code != harness.Pass { | |||
| 131 | + | errOut, _ := os.ReadFile(result.Artifacts["client_stderr"]) | |||
| 132 | + | t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut) | |||
| 133 | + | } | |||
| 134 | + | t.Logf("client output:\n%s", out) | |||
| 135 | + | // Frames coalesce, so only the shape is asserted: the mask rises, Escape | |||
| 136 | + | // clears it, and the accepted secret is wiped. | |||
| 137 | + | for _, want := range []string{"LOCK locked", "LOCK frame", "LOCK submit ok", "LOCK unlocked [0 ", "LOCK wake stopped"} { | |||
| 138 | + | if !strings.Contains(string(out), want) { | |||
| 139 | + | t.Fatalf("missing %q in:\n%s", want, out) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | // A mask above zero was drawn before the final wipe to zero. | |||
| 143 | + | if !regexp.MustCompile(`LOCK unlocked \[0( [0-9]+)* [1-9][0-9]* 0\]`).Match(out) { | |||
| 144 | + | t.Fatalf("no mask above 0 before the final 0 in:\n%s", out) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestHarnessRunLockClient(t *testing.T) { | |||
| 149 | + | if os.Getenv("NEFERGUI_DEBUG_DIR") == "" { | |||
| 150 | + | t.Skip("harness child only") | |||
| 151 | + | } | |||
| 152 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 153 | + | defer cancel() | |||
| 154 | + | secret := NewSecretBuffer(64) | |||
| 155 | + | unlock, status := make(chan struct{}, 1), make(chan LockStatus, 4) | |||
| 156 | + | frames := 0 | |||
| 157 | + | var maskSeen []int | |||
| 158 | + | wake := make(chan struct{}) | |||
| 159 | + | err := RunLock(ctx, LockConfig{ | |||
| 160 | + | Secret: secret, | |||
| 161 | + | Wake: wake, | |||
| 162 | + | View: func(f *Frame, s LockState) { | |||
| 163 | + | if frames == 0 { | |||
| 164 | + | fmt.Println("LOCK frame") | |||
| 165 | + | } | |||
| 166 | + | frames++ | |||
| 167 | + | if len(maskSeen) == 0 || maskSeen[len(maskSeen)-1] != s.Mask { | |||
| 168 | + | maskSeen = append(maskSeen, s.Mask) | |||
| 169 | + | } | |||
| 170 | + | col := f.Root().Column() | |||
| 171 | + | col.Text(s.Bullets(), Key("mask")) | |||
| 172 | + | }, | |||
| 173 | + | OnLocked: func() { fmt.Println("LOCK locked") }, | |||
| 174 | + | Unlock: unlock, | |||
| 175 | + | Status: status, | |||
| 176 | + | OnSubmit: func(b []byte) { | |||
| 177 | + | // Test-only comparison; real callers hand the bytes to a verifier. | |||
| 178 | + | if string(b) == "open" { | |||
| 179 | + | fmt.Println("LOCK submit ok") | |||
| 180 | + | status <- LockBusy | |||
| 181 | + | go func() { time.Sleep(300 * time.Millisecond); unlock <- struct{}{} }() | |||
| 182 | + | } | |||
| 183 | + | }, | |||
| 184 | + | }) | |||
| 185 | + | if err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | if secret.Len() != 0 { | |||
| 189 | + | t.Fatal("secret not wiped after submit") | |||
| 190 | + | } | |||
| 191 | + | select { | |||
| 192 | + | case wake <- struct{}{}: | |||
| 193 | + | t.Fatal("wake forwarder outlived RunLock") | |||
| 194 | + | case <-time.After(200 * time.Millisecond): | |||
| 195 | + | fmt.Println("LOCK wake stopped") | |||
| 196 | + | } | |||
| 197 | + | fmt.Println("LOCK unlocked", maskSeen) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestOutputFailurePolicy(t *testing.T) { | |||
| 201 | + | boom := errors.New("boom") | |||
| 202 | + | if err := outputFailure(true, "lock surface", "DP-1", boom); !errors.Is(err, boom) { | |||
| 203 | + | t.Fatalf("initial output failure must abort: %v", err) | |||
| 204 | + | } | |||
| 205 | + | if err := outputFailure(false, "lock surface", "DP-2", boom); err != nil { | |||
| 206 | + | t.Fatalf("hotplug failure must be skipped: %v", err) | |||
| 207 | + | } | |||
| 208 | + | var reported []string | |||
| 209 | + | l := &locker{cfg: LockConfig{OnOutputError: func(o string, err error) { | |||
| 210 | + | if errors.Is(err, boom) { | |||
| 211 | + | reported = append(reported, o) | |||
| 212 | + | } | |||
| 213 | + | }}} | |||
| 214 | + | if err := l.outputFailure(false, "lock surface", "DP-4", boom); err != nil || len(reported) != 1 || reported[0] != "DP-4" { | |||
| 215 | + | t.Fatalf("hotplug failure must be reported and skipped: %v %v", err, reported) | |||
| 216 | + | } | |||
| 217 | + | if err := l.outputFailure(true, "lock surface", "DP-5", boom); err == nil || len(reported) != 1 { | |||
| 218 | + | t.Fatalf("initial failure must abort without a skip report: %v %v", err, reported) | |||
| 219 | + | } | |||
| 220 | + | for _, initial := range []bool{true, false} { | |||
| 221 | + | err := outputFailure(initial, "lock surface", "DP-3", fmt.Errorf("x: %w", wayland.ErrLockFinished)) | |||
| 222 | + | if !errors.Is(err, ErrLockFinished) || !errors.Is(err, wayland.ErrLockFinished) { | |||
| 223 | + | t.Fatalf("finished (initial=%v) must map to ErrLockFinished: %v", initial, err) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // Events kept during setup reach handle() exactly as reader events do. | |||
| 229 | + | func TestHeldLockEventsDriveLocker(t *testing.T) { | |||
| 230 | + | onLocked := 0 | |||
| 231 | + | l := &locker{conn: &wayland.Connection{}, cfg: LockConfig{OnLocked: func() { onLocked++ }}} | |||
| 232 | + | if err := l.handle(wayland.Event{Kind: wayland.LockAcquired}); err != nil || !l.locked || onLocked != 1 { | |||
| 233 | + | t.Fatalf("locked lost: err=%v locked=%v calls=%d", err, l.locked, onLocked) | |||
| 234 | + | } | |||
| 235 | + | l.unlock = true | |||
| 236 | + | if !l.shouldUnlock() { | |||
| 237 | + | t.Fatal("unlock not possible after a held locked") | |||
| 238 | + | } | |||
| 239 | + | if err := l.handle(wayland.Event{Kind: wayland.LockFinished}); !errors.Is(err, ErrLockFinished) { | |||
| 240 | + | t.Fatalf("finished: %v", err) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // A second locker is refused by the compositor with `finished`, which must | |||
| 245 | + | // surface as ErrLockFinished however early it arrives during setup. | |||
| 246 | + | func TestHarnessRunLockRefused(t *testing.T) { | |||
| 247 | + | if os.Getenv("NEFERGUI_HARNESS") != "1" { | |||
| 248 | + | t.Skip("set NEFERGUI_HARNESS=1 and NEFERGUI_NEFERWL for the session-lock harness") | |||
| 249 | + | } | |||
| 250 | + | result, code := harness.Run(context.Background(), harness.Options{Size: "400x300", Scale: "1", Layout: "us", Background: "#111111", Out: filepath.Join(t.TempDir(), "artifacts"), AllowUnpinned: os.Getenv("NEFERGUI_NEFERWL") != "", Timeout: 40 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{os.Args[0], "-test.run=^TestHarnessRunLockRefusedClient$", "-test.v"}}) | |||
| 251 | + | out, _ := os.ReadFile(result.Artifacts["client_stdout"]) | |||
| 252 | + | // A protected compositor withholds debug PNGs by design, so the harness may | |||
| 253 | + | // report a missing frame; this test only needs the client's refusal result. | |||
| 254 | + | noFrame := code == harness.ProcessFailure && strings.HasPrefix(result.Error, "no frame") | |||
| 255 | + | if (code != harness.Pass && !noFrame) || !strings.Contains(string(out), "LOCK refused ok") { | |||
| 256 | + | errOut, _ := os.ReadFile(result.Artifacts["client_stderr"]) | |||
| 257 | + | t.Fatalf("code=%d %s\nstdout:\n%s\nstderr:\n%s", code, result.Error, out, errOut) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestHarnessRunLockRefusedClient(t *testing.T) { | |||
| 262 | + | if os.Getenv("NEFERGUI_DEBUG_DIR") == "" { | |||
| 263 | + | t.Skip("harness child only") | |||
| 264 | + | } | |||
| 265 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 266 | + | defer cancel() | |||
| 267 | + | first, err := wayland.ConnectConnection(ctx, "") | |||
| 268 | + | if err != nil { | |||
| 269 | + | t.Fatal(err) | |||
| 270 | + | } | |||
| 271 | + | defer first.Close() | |||
| 272 | + | if err = first.AcquireLock(); err != nil { | |||
| 273 | + | t.Fatal(err) | |||
| 274 | + | } | |||
| 275 | + | // The first owner stays alive and unanswered; the compositor refuses the next. | |||
| 276 | + | err = RunLock(ctx, LockConfig{Secret: NewSecretBuffer(8), View: func(*Frame, LockState) {}}) | |||
| 277 | + | if !errors.Is(err, ErrLockFinished) { | |||
| 278 | + | t.Fatalf("refused lock: %v", err) | |||
| 279 | + | } | |||
| 280 | + | fmt.Println("LOCK refused ok") | |||
| 281 | + | } | |||
M nefergui.go +26 -0
| @@ -176,3 +176,29 @@ func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options .. | |||
| 176 | 176 | func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error { | |
| 177 | 177 | return ui.RunFrames(ctx, frames, model, view, committed, options...) | |
| 178 | 178 | } | |
| 179 | + | ||
| 180 | + | // Session lock. RunLock acquires ext-session-lock, covers every output with | |
| 181 | + | // opaque black through the normal Vulkan/DMA-BUF/explicit-sync path and hosts | |
| 182 | + | // the caller's view on one output. See docs/session-lock.md. | |
| 183 | + | type ( | |
| 184 | + | LockConfig = ui.LockConfig | |
| 185 | + | LockState = ui.LockState | |
| 186 | + | LockStatus = ui.LockStatus | |
| 187 | + | SecretBuffer = ui.SecretBuffer | |
| 188 | + | ) | |
| 189 | + | ||
| 190 | + | const ( | |
| 191 | + | LockIdle = ui.LockIdle | |
| 192 | + | LockBusy = ui.LockBusy | |
| 193 | + | LockFailed = ui.LockFailed | |
| 194 | + | ) | |
| 195 | + | ||
| 196 | + | // ErrLockFinished reports that the compositor refused or ended the lock. | |
| 197 | + | var ErrLockFinished = ui.ErrLockFinished | |
| 198 | + | ||
| 199 | + | // NewSecretBuffer allocates a caller-owned secret input buffer (1..4096 bytes). | |
| 200 | + | func NewSecretBuffer(capacity int) *SecretBuffer { return ui.NewSecretBuffer(capacity) } | |
| 201 | + | ||
| 202 | + | // RunLock runs a session lock until an unlock requested on LockConfig.Unlock, | |
| 203 | + | // a failure, or cancellation. It never unlocks on failure or cancellation. | |
| 204 | + | func RunLock(ctx context.Context, cfg LockConfig) error { return ui.RunLock(ctx, cfg) } | |
| @@ -176,3 +176,29 @@ func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options .. | |||||
| 176 | func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error { | 176 | func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error { | ||
| 177 | return ui.RunFrames(ctx, frames, model, view, committed, options...) | 177 | return ui.RunFrames(ctx, frames, model, view, committed, options...) | ||
| 178 | } | 178 | } | ||
| 179 | + | ||||
| 180 | + | // Session lock. RunLock acquires ext-session-lock, covers every output with | |||
| 181 | + | // opaque black through the normal Vulkan/DMA-BUF/explicit-sync path and hosts | |||
| 182 | + | // the caller's view on one output. See docs/session-lock.md. | |||
| 183 | + | type ( | |||
| 184 | + | LockConfig = ui.LockConfig | |||
| 185 | + | LockState = ui.LockState | |||
| 186 | + | LockStatus = ui.LockStatus | |||
| 187 | + | SecretBuffer = ui.SecretBuffer | |||
| 188 | + | ) | |||
| 189 | + | ||||
| 190 | + | const ( | |||
| 191 | + | LockIdle = ui.LockIdle | |||
| 192 | + | LockBusy = ui.LockBusy | |||
| 193 | + | LockFailed = ui.LockFailed | |||
| 194 | + | ) | |||
| 195 | + | ||||
| 196 | + | // ErrLockFinished reports that the compositor refused or ended the lock. | |||
| 197 | + | var ErrLockFinished = ui.ErrLockFinished | |||
| 198 | + | ||||
| 199 | + | // NewSecretBuffer allocates a caller-owned secret input buffer (1..4096 bytes). | |||
| 200 | + | func NewSecretBuffer(capacity int) *SecretBuffer { return ui.NewSecretBuffer(capacity) } | |||
| 201 | + | ||||
| 202 | + | // RunLock runs a session lock until an unlock requested on LockConfig.Unlock, | |||
| 203 | + | // a failure, or cancellation. It never unlocks on failure or cancellation. | |||
| 204 | + | func RunLock(ctx context.Context, cfg LockConfig) error { return ui.RunLock(ctx, cfg) } | |||