detached 65d5b9b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
5757 - [CSS properties and limitations](docs/css.md)
5858 - [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md)
5959 - [Visual test harness](docs/harness.md) and [performance](docs/performance.md)
60+- [Session lock client](docs/session-lock.md)
6061 - [Architecture decisions](docs/adr)
6162
6263 ## 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 ## License63 ## 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 (
1313 )
1414
1515 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 // indirect15 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=
91 github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
102 github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
113 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 {
2424 state *xkb.State
2525 compose *xkb.ComposeState
2626 held map[uint32]Key
27+ secretHeld map[uint32]SecretKey
28+ secretMode bool
2729 rate int
2830 delay time.Duration
2931 next time.Time
@@ -120,7 +122,10 @@ func (k *Keyboard) Mask(depressed, latched, locked, groupDepressed, groupLatched
120122 func (k *Keyboard) Focus(on bool) {
121123 k.focus = on
122124 k.held = make(map[uint32]Key)
125+ clear(k.secretHeld)
126+ k.secretMode = false
123127 k.repeating = 0
128+ k.next = time.Time{}
124129 if k.compose != nil {
125130 k.compose.Reset()
126131 }
@@ -134,6 +139,9 @@ func (k *Keyboard) RepeatInfo(rate int, delay time.Duration) {
134139 }
135140 }
136141 func (k *Keyboard) Event(evdev uint32, pressed bool, now time.Time) (Key, error) {
142+ if k.secretMode {
143+ k.Focus(k.focus)
144+ }
137145 if !pressed {
138146 key := k.held[evdev]
139147 delete(k.held, evdev)
@@ -210,7 +218,7 @@ func repeatable(sym uint32) bool {
210218 // Compose is fed only by physical presses: synthetic repeats neither advance a
211219 // compose sequence nor replay the press-time composed text.
212220 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) {
214222 return Key{}, false
215223 }
216224 physical := k.repeating
@@ -24,6 +24,8 @@ type Keyboard struct {
24 state *xkb.State24 state *xkb.State
25 compose *xkb.ComposeState25 compose *xkb.ComposeState
26 held map[uint32]Key26 held map[uint32]Key
27+ secretHeld map[uint32]SecretKey
28+ secretMode bool
27 rate int29 rate int
28 delay time.Duration30 delay time.Duration
29 next time.Time31 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 = on123 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 = 0127 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 a218 // 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{}, false222 return Key{}, false
215 }223 }
216 physical := k.repeating224 physical := k.repeating
M internal/keyboard/keyboard_test.go +22 -6
@@ -1,11 +1,13 @@
11 package keyboard
22
33 import (
4+ "errors"
45 "os"
56 "testing"
67 "time"
78
89 "github.com/bnema/purego-xkbcommon/raw"
10+ "golang.org/x/sys/unix"
911 )
1012
1113 func TestLayoutsAndRepeat(t *testing.T) {
@@ -102,14 +104,22 @@ func TestKeymapFDReplacement(t *testing.T) {
102104 if err != nil {
103105 t.Fatal(err)
104106 }
107+ defer f.Close()
105108 if _, err = f.WriteString("bad keymap"); err != nil {
106109 t.Fatal(err)
107110 }
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 {
109118 t.Fatal("bad keymap accepted")
110119 }
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)
113123 }
114124 if err = k.ReplaceFD(-1, 0); err == nil {
115125 t.Fatal("invalid fd accepted")
@@ -141,14 +151,20 @@ func TestValidFDReplacementCancelsRepeat(t *testing.T) {
141151 if err != nil {
142152 t.Fatal(err)
143153 }
154+ defer file.Close()
144155 if _, err = file.Write(append(data, 0)); err != nil {
145156 t.Fatal(err)
146157 }
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 {
148163 t.Fatal(err)
149164 }
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)
152168 }
153169 if _, ok := k.Tick(now.Add(time.Second)); ok {
154170 t.Fatal("repeat survived keymap replacement")
@@ -1,11 +1,13 @@
1 package keyboard1 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 (
3333 FeedbackMainDevice
3434 FeedbackTranche
3535 FeedbackFormats
36+ FeedbackComplete
3637 ClipboardInput
3738 IMEInput
3839 // ConfigureLayer carries a layer-surface extent and its configure serial;
3940 // Apply resizes and acks together. Zero axes are unchanged.
4041 ConfigureLayer
42+ ConfigureLock
43+ OutputGlobal
44+ OutputNamed
45+ OutputScale
46+ OutputAdded
47+ OutputRemoved
48+ LockAcquired
49+ LockFinished
4150 )
4251
4352 type Event struct {
53+ Window *Window // tagged by transport, applied only by the shared UI owner
54+ Output Output
55+ Version uint32
4456 Kind EventKind
4557 Width, Height int32
4658 Serial, Scale uint32
@@ -62,6 +74,9 @@ type Event struct {
6274 // StartReader launches exactly one blocking Wayland reader. Registered callbacks
6375 // only send typed values to Events; Apply is called by the session loop alone.
6476 func (w *Window) StartReader() <-chan Event {
77+ if w.connection != nil {
78+ return w.connection.StartReader()
79+ }
6580 w.readerOnce.Do(func() {
6681 w.readerStarted.Store(true)
6782 w.readerDone = make(chan struct{})
@@ -86,11 +101,28 @@ func (w *Window) StartReader() <-chan Event {
86101 // Events exposes the reader's typed event queue to the session owner.
87102 func (w *Window) Events() <-chan Event { return w.events }
88103
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+
89114 // PostClipboard and PostIME enqueue transport callbacks for the owner loop.
90115 func (w *Window) PostClipboard(ev ClipboardEvent) { w.post(Event{Kind: ClipboardInput, Clipboard: ev}) }
91116 func (w *Window) PostIME(ev IMEEvent) { w.post(Event{Kind: IMEInput, IME: ev}) }
92117
93118 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+ }
94126 select {
95127 case <-w.readerStop:
96128 return
@@ -100,6 +132,10 @@ func (w *Window) post(ev Event) {
100132
101133 // postFD closes an undeliverable keymap descriptor rather than leaking it.
102134 func (w *Window) postFD(ev Event) {
135+ if w.connection != nil {
136+ w.post(ev)
137+ return
138+ }
103139 select {
104140 case <-w.readerStop:
105141 CloseEventFD(ev)
@@ -113,6 +149,13 @@ func (w *Window) BufferReleased(id uint64) {
113149 w.post(Event{Kind: BufferRelease, BufferID: id})
114150 }
115151 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+ }
116159 switch ev.Kind {
117160 case ConfigureSize:
118161 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 {
129172 return err
130173 }
131174 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
132184 case ConfigureLayer:
133185 w.applyLayerSize(ev.Width, ev.Height)
134186 if w.LayerSurface == nil {
@@ -164,16 +216,37 @@ func (w *Window) Apply(ev Event) error {
164216 }
165217 return ev.Err
166218 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+ }
167222 w.MainDevice = ev.Device
168223 case FeedbackTranche:
169224 w.Tranches = append(w.Tranches, ev.Tranche)
170225 case FeedbackFormats:
171226 w.Formats = ev.Formats
227+ case FeedbackComplete:
228+ if w.feedbackErr != nil {
229+ return w.feedbackErr
230+ }
231+ w.FeedbackDone = true
172232 case SeatCapabilities, SeatRemoved:
173233 return w.ApplySeat(ev)
174234 case ClipboardInput, IMEInput:
175235 // These are applied by the session owner after Window.Apply.
176236 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+ }
177250 if w.inputAdapter == nil {
178251 var err error
179252 w.inputAdapter, err = NewInputAdapter("")
@@ -33,14 +33,26 @@ const (
33 FeedbackMainDevice33 FeedbackMainDevice
34 FeedbackTranche34 FeedbackTranche
35 FeedbackFormats35 FeedbackFormats
36+ FeedbackComplete
36 ClipboardInput37 ClipboardInput
37 IMEInput38 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 ConfigureLayer41 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 EventKind56 Kind EventKind
45 Width, Height int3257 Width, Height int32
46 Serial, Scale uint3258 Serial, Scale uint32
@@ -62,6 +74,9 @@ type Event struct {
62 // StartReader launches exactly one blocking Wayland reader. Registered callbacks74 // 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 return128 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 err172 return err
130 }173 }
131 w.Configured, w.FrameReady = true, true174 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.Err217 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.Device222 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.Formats226 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 error251 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)
150150 func (w *Window) ApplySeat(ev Event) error {
151151 switch ev.Kind {
152152 case SeatRemoved:
153- if w.WLKeyboard != nil {
154- w.post(Event{Kind: InputFocusOut})
153+ if w.WLKeyboard != nil && w.connection == nil {
154+ w.ownerFocusOut()
155155 }
156156 return w.releaseSeat()
157157 case SeatCapabilities:
@@ -166,7 +166,13 @@ func (w *Window) ApplySeat(ev Event) error {
166166 if ev.Capabilities&2 == 0 && w.WLKeyboard != nil {
167167 w.releaseDevice(w.WLKeyboard)
168168 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+ }
170176 }
171177 if ev.Capabilities&1 != 0 && w.Pointer == nil {
172178 p, err := w.Seat.GetPointer()
@@ -201,7 +207,38 @@ func (w *Window) releaseDevice(p interface {
201207 c.Unregister(p)
202208 }
203209 }
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+
204237 func (w *Window) releaseSeat() error {
238+ if w.keymapCache != nil {
239+ w.keymapCache.close()
240+ w.keymapCache = nil
241+ }
205242 if w.Pointer != nil {
206243 w.dropCursorDevice()
207244 w.releaseDevice(w.Pointer)
@@ -222,6 +259,10 @@ func (w *Window) releaseSeat() error {
222259 return nil
223260 }
224261 func (w *Window) watchPointer(p *core.Pointer) {
262+ if w.connection != nil {
263+ w.watchSharedPointer(p)
264+ return
265+ }
225266 var frame []Input // only accessed by the reader
226267 var x, y float64
227268 var inside bool
@@ -300,6 +341,10 @@ func (w *Window) watchPointer(p *core.Pointer) {
300341 p.OnFrame(flush)
301342 }
302343 func (w *Window) watchKeyboard(k *core.Keyboard) {
344+ if w.connection != nil {
345+ w.watchSharedKeyboard(k)
346+ return
347+ }
303348 surfaceID := w.Surface.ID()
304349 k.OnKeymap(func(format uint32, fd *wl.OwnedFD, size uint32) {
305350 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 = nil168 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 nil259 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 reader266 var frame []Input // only accessed by the reader
226 var x, y float64267 var x, y float64
227 var inside bool268 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 {
8282 type SurfaceOptions struct {
8383 // Layer, when non-nil, creates a layer surface and binds no xdg shell.
8484 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
8588 // InputRects is the initial input region in surface-local logical pixels.
8689 // nil means the whole surface (the protocol default); a non-nil empty slice
8790 // means no input (pointer and touch pass through).
@@ -90,6 +93,9 @@ type SurfaceOptions struct {
9093
9194 // Validate reports the first invalid option.
9295 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+ }
9399 if o.Layer != nil {
94100 if err := o.Layer.Validate(); err != nil {
95101 return err
@@ -246,6 +252,14 @@ func releaseOutputs(cands []*outputCandidate, keep *outputCandidate) {
246252 // selectOutput binds every wl_output that can report a name and returns the
247253 // global name of the exact match. Unmatched outputs are released.
248254 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+ }
249263 reg := w.Display.Registry()
250264 var globals []wl.Global
251265 legacy := 0 // outputs older than v4 cannot report a name
@@ -317,8 +331,9 @@ func (w *Window) createLayerSurface(o LayerOptions) error {
317331 ns = DefaultLayerNamespace
318332 }
319333 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.
322337 _ = out.Release()
323338 }
324339 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 *LayerOptions84 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 slice89 // 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 err101 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 the252 // 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.Global264 var globals []wl.Global
251 legacy := 0 // outputs older than v4 cannot report a name265 legacy := 0 // outputs older than v4 cannot report a name
@@ -317,8 +331,9 @@ func (w *Window) createLayerSurface(o LayerOptions) error {
317 ns = DefaultLayerNamespace331 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) {
6060 p.SetID(uint32(20 + i))
6161 ctx.Register(p)
6262 }
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}
6464 p, _ := buffers.New(1)
6565 // The fake server receives a finite number of requests; no dispatch loop/GPU.
6666 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 (
1111 "errors"
1212 "fmt"
1313 "math"
14- "net"
1514 "os"
1615 "path/filepath"
1716 "sync"
@@ -22,6 +21,7 @@ import (
2221 "github.com/bnema/wlturbo/protocol/core"
2322 "github.com/bnema/wlturbo/protocol/cursorshape"
2423 "github.com/bnema/wlturbo/protocol/drmsyncobj"
24+ "github.com/bnema/wlturbo/protocol/extsessionlock"
2525 "github.com/bnema/wlturbo/protocol/fractionalscale"
2626 "github.com/bnema/wlturbo/protocol/linuxdmabuf"
2727 "github.com/bnema/wlturbo/protocol/viewporter"
@@ -48,6 +48,9 @@ const (
4848 )
4949
5050 type Window struct {
51+ connection *Connection
52+ ownsConnection bool
53+ LockSurface *extsessionlock.ExtSessionLockSurface
5154 Display *wl.Display
5255 Compositor *core.Compositor
5356 Shell *xdgshell.XdgWmBase
@@ -70,6 +73,7 @@ type Window struct {
7073 cursor cursorState
7174 pointerGen uint64 // bumped per wl_pointer; session loop only
7275 WLKeyboard *core.Keyboard
76+ keymapCache *keyboardMapCache // owner retires each keyboard generation
7377 seatName, seatVersion uint32
7478 Width, Height int32
7579 Scale float64
@@ -77,6 +81,7 @@ type Window struct {
7781 Transparent bool
7882 FrameReady bool
7983 // Feedback fields are populated by the default feedback event stream.
84+ FeedbackDone bool // owner has applied the complete feedback round
8085 MainDevice uint64
8186 Formats []linuxdmabuf.FormatEntry
8287 Tranches [][]linuxdmabuf.FormatEntry
@@ -86,12 +91,14 @@ type Window struct {
8691 readerStop chan struct{}
8792 readerOnce sync.Once
8893 readerStarted atomic.Bool
94+ rawInput []Event // lock-only numeric protocol events, never ordinary text
8995 inputAdapter *InputAdapter
9096 inputQueue []InputEvent
9197 // inputRects/inputCustom record the current input region; session loop only.
9298 inputRects []Rect
9399 inputCustom bool
94100 layerOutputGlobal uint32
101+ lockOutput uint32
95102 layerOutputSelected bool
96103 // InputOverflow counts events discarded when the 4096-event queue fills.
97104 InputOverflow uint64
@@ -129,35 +136,66 @@ func waylandSocketPath(name string) (string, error) {
129136 // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the
130137 // connection is closed and the returned error wraps ctx.Err(). Once it
131138 // 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) {
133140 if ctx == nil {
134141 return nil, errors.New("nil context")
135142 }
136143 if width <= 0 || height <= 0 {
137144 return nil, fmt.Errorf("invalid logical size %dx%d", width, height)
138145 }
139- if err = opts.Validate(); err != nil {
146+ if err := opts.Validate(); err != nil {
140147 return nil, err
141148 }
142- if err = ctx.Err(); err != nil {
149+ c, err := ConnectConnection(ctx, name)
150+ if err != nil {
143151 return nil, err
144152 }
145- path, err := waylandSocketPath(name)
153+ w, err := c.NewWindow(ctx, width, height, transparent, opts)
146154 if err != nil {
155+ _ = c.Close()
147156 return nil, err
148157 }
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")
153168 }
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 {
156176 return nil, err
157177 }
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
158195 // Closing the display unblocks any pending read; Display.Close is idempotent.
159196 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())
161199 defer func() {
162200 if !stopWatch() && err == nil {
163201 // 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
172210 res = nil
173211 }
174212 }()
175- if err = d.Roundtrip(); err != nil {
176- return nil, err
177- }
178213 wctx := d.Context()
179214 bind := func(name string, supported, minimum uint32, proxy wl.Proxy) error {
180215 advertised, ok := d.Registry().FindGlobal(name)
@@ -198,7 +233,7 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t
198233 if err = w.bindLayerShell(bind); err != nil {
199234 return nil, err
200235 }
201- } else {
236+ } else if opts.Lock == nil {
202237 w.Shell = xdgshell.NewXdgWmBase(wctx)
203238 if err = bind(xdgshell.XdgWmBaseInterface, 6, 1, w.Shell); err != nil {
204239 return nil, err
@@ -236,9 +271,11 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t
236271 wctx.Unregister(w.CursorShapes)
237272 w.CursorShapes = nil
238273 }
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
242279 if w.Shell != nil {
243280 w.Shell.OnPing(func(serial uint32) {
244281 if e := w.Shell.Pong(serial); e != nil {
@@ -290,7 +327,13 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t
290327 w.Dirty = true
291328 }
292329 })
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 {
294337 if err = w.createLayerSurface(*opts.Layer); err != nil {
295338 return nil, err
296339 }
@@ -311,16 +354,29 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t
311354 return nil, err
312355 }
313356 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 {
318368 if w.Closed {
319369 return nil, fmt.Errorf("window closed before initial configure and feedback")
320370 }
371+ if err = c.drainStartup(true); err != nil {
372+ return nil, err // a finished already queued: do not wait for a configure
373+ }
321374 if err = w.Dispatch(); err != nil {
322375 return nil, err
323376 }
377+ if err = c.drainStartup(true); err != nil {
378+ return nil, err
379+ }
324380 }
325381 return w, nil
326382 }
@@ -366,6 +422,9 @@ func (w *Window) createToplevel() error {
366422 }
367423
368424 func (w *Window) Dispatch() error {
425+ if w.connection != nil && w.connection.started.Load() {
426+ return errors.New("connection reader already started")
427+ }
369428 if w.feedbackErr != nil {
370429 return w.feedbackErr
371430 }
@@ -375,6 +434,14 @@ func (w *Window) Dispatch() error {
375434 return w.feedbackErr
376435 }
377436
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+
378445 func (w *Window) PhysicalSize() (int32, int32, error) {
379446 if w.Scale <= 0 || w.Width <= 0 || w.Height <= 0 {
380447 return 0, 0, fmt.Errorf("invalid window geometry")
@@ -396,6 +463,9 @@ func (w *Window) Present(buffer *core.Buffer, acquire, release *drmsyncobj.WpLin
396463 if !w.Configured || buffer == nil || acquire == nil || release == nil {
397464 return fmt.Errorf("unconfigured surface or missing buffer/timeline")
398465 }
466+ if !w.FeedbackDone {
467+ return fmt.Errorf("dmabuf feedback incomplete")
468+ }
399469 if !w.FrameReady {
400470 return fmt.Errorf("frame callback pending")
401471 }
@@ -478,15 +548,24 @@ func (w *Window) Close() error {
478548 if w == nil {
479549 return nil
480550 }
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
481559 // The loop sends protocol destructors before Close; the reader is
482560 // unblocked by the connection close at the end of this method.
483561 if w.inputAdapter != nil {
484562 w.inputAdapter.Close()
485563 w.inputAdapter = nil
486564 }
487- if w.Seat != nil {
565+ if w.Seat != nil && w.connection == nil {
488566 _ = w.releaseSeat()
489567 }
568+ w.dropCursorDevice()
490569 if w.CursorShapes != nil {
491570 _ = w.CursorShapes.Destroy()
492571 w.CursorShapes = nil
@@ -499,6 +578,14 @@ func (w *Window) Close() error {
499578 _ = w.SyncSurface.Destroy()
500579 w.SyncSurface = nil
501580 }
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+ }
502589 w.closeLayer() // before wl_surface
503590 if w.Toplevel != nil {
504591 _ = w.Toplevel.Destroy()
@@ -548,6 +635,14 @@ func (w *Window) Close() error {
548635 }
549636 w.Compositor = nil
550637 }
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+ }
551646 if w.Display != nil {
552647 if w.readerDone != nil {
553648 close(w.readerStop)
@@ -609,8 +704,10 @@ func (w *Window) watchFeedback() {
609704 w.Feedback.OnDone(func() {
610705 if w.readerStarted.Load() {
611706 w.post(Event{Kind: FeedbackFormats, Formats: table})
707+ w.post(Event{Kind: FeedbackComplete})
612708 } else {
613709 w.Formats = table
710+ w.FeedbackDone = true
614711 }
615712 })
616713 }
@@ -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.Display54 Display *wl.Display
52 Compositor *core.Compositor55 Compositor *core.Compositor
53 Shell *xdgshell.XdgWmBase56 Shell *xdgshell.XdgWmBase
@@ -70,6 +73,7 @@ type Window struct {
70 cursor cursorState73 cursor cursorState
71 pointerGen uint64 // bumped per wl_pointer; session loop only74 pointerGen uint64 // bumped per wl_pointer; session loop only
72 WLKeyboard *core.Keyboard75 WLKeyboard *core.Keyboard
76+ keymapCache *keyboardMapCache // owner retires each keyboard generation
73 seatName, seatVersion uint3277 seatName, seatVersion uint32
74 Width, Height int3278 Width, Height int32
75 Scale float6479 Scale float64
@@ -77,6 +81,7 @@ type Window struct {
77 Transparent bool81 Transparent bool
78 FrameReady bool82 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 uint6485 MainDevice uint64
81 Formats []linuxdmabuf.FormatEntry86 Formats []linuxdmabuf.FormatEntry
82 Tranches [][]linuxdmabuf.FormatEntry87 Tranches [][]linuxdmabuf.FormatEntry
@@ -86,12 +91,14 @@ type Window struct {
86 readerStop chan struct{}91 readerStop chan struct{}
87 readerOnce sync.Once92 readerOnce sync.Once
88 readerStarted atomic.Bool93 readerStarted atomic.Bool
94+ rawInput []Event // lock-only numeric protocol events, never ordinary text
89 inputAdapter *InputAdapter95 inputAdapter *InputAdapter
90 inputQueue []InputEvent96 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 []Rect98 inputRects []Rect
93 inputCustom bool99 inputCustom bool
94 layerOutputGlobal uint32100 layerOutputGlobal uint32
101+ lockOutput uint32
95 layerOutputSelected bool102 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 uint64104 InputOverflow uint64
@@ -129,35 +136,66 @@ func waylandSocketPath(name string) (string, error) {
129 // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the136 // (dial, registry, configure, dmabuf feedback) is abandoned with ctx: the
130 // connection is closed and the returned error wraps ctx.Err(). Once it137 // 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, err147 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, err151 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, err156 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, err176 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 a201 // 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 = nil210 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, err234 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, err239 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 = nil272 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, err275+ 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 = true327 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, err338 return nil, err
296 }339 }
@@ -311,16 +354,29 @@ func ConnectWithOptions(ctx context.Context, name string, width, height int32, t
311 return nil, err354 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, err358+ // layer-shell it forbids the initial empty commit before ACK+buffer.
316- } // no buffer before configure359+ 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, err375 return nil, err
323 }376 }
377+ if err = c.drainStartup(true); err != nil {
378+ return nil, err
379+ }
324 }380 }
325 return w, nil381 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.feedbackErr429 return w.feedbackErr
371 }430 }
@@ -375,6 +434,14 @@ func (w *Window) Dispatch() error {
375 return w.feedbackErr434 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 nil549 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 is559 // 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 = nil563 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 = nil571 w.CursorShapes = nil
@@ -499,6 +578,14 @@ func (w *Window) Close() error {
499 _ = w.SyncSurface.Destroy()578 _ = w.SyncSurface.Destroy()
500 w.SyncSurface = nil579 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_surface589 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 = nil636 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 = table709 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 {
6262 }
6363 }
6464 }
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) {
6666 return s.tickFrame(prepared)
6767 })
6868 if err != nil {
@@ -77,7 +77,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
7777 continue
7878 }
7979 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()) {
8181 retry = time.After(retryInterval)
8282 }
8383 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 continue77 continue
78 }78 }
79 var retry, repeat <-chan time.Time79 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 {
7676 waitCancel context.CancelFunc
7777 waiters sync.WaitGroup
7878 releases chan releaseEvent
79+ wake chan<- struct{} // optional: pinged after a release is queued
7980 }
8081
8182 type retiredPipelines struct {
@@ -165,6 +166,17 @@ func OpenWithOptions(ctx context.Context, name string, width, height int32, tran
165166 if err != nil {
166167 return nil, err
167168 }
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+ }
168180 s := &Session{Window: w, Transparent: transparent, Slots: make(map[uint64]*Slot)}
169181 if dir := os.Getenv("NEFERGUI_DEBUG_DIR"); dir != "" {
170182 s.debugDir = dir
@@ -526,6 +538,12 @@ func (s *Session) startReleaseWait(slot *Slot, point uint64) {
526538 if err != nil || ready {
527539 select {
528540 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+ }
529547 case <-ctx.Done():
530548 }
531549 return
@@ -808,8 +826,12 @@ func (s *Session) Close() (err error) {
808826 }()
809827 }
810828 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+ }
813835 _ = s.Window.DestroySyncSurface()
814836 }
815837 // Drain detach/release points for tracing, not for freeing allocations. The
@@ -831,7 +853,7 @@ func (s *Session) Close() (err error) {
831853 select {
832854 case ev := <-s.releases:
833855 _ = s.release(ev)
834- case ev := <-s.Window.Events():
856+ case ev := <-s.Window.ReaderEvents(): // nil on a shared connection
835857 if ev.Kind == wayland.BufferRelease {
836858 if slot := s.Slots[ev.BufferID]; slot != nil {
837859 _ = s.Pool.Release(slot.Buffer)
@@ -76,6 +76,7 @@ type Session struct {
76 waitCancel context.CancelFunc76 waitCancel context.CancelFunc
77 waiters sync.WaitGroup77 waiters sync.WaitGroup
78 releases chan releaseEvent78 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, err167 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 = dir182 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 return549 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. The837 // 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 ..
176176 func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
177177 return ui.RunFrames(ctx, frames, model, view, committed, options...)
178178 }
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) }

Keyboard shortcuts

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

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

Branches 1

Tags 12

↑↓ moveenter openesc close