1package wayland
2
3import (
4 "cmp"
5 "slices"
6
7 "github.com/bnema/go-wayland-bindings/server/extsessionlock"
8 "github.com/bnema/go-wayland-bindings/server/wayland"
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// lockConfigure is an issued configure, or the last acknowledged configure.
13// It is copied into each commit: a later ACK cannot change a queued response.
14type lockConfigure struct {
15 serial uint32
16 width, height int
17}
18
19type lockSurface struct {
20 resource *extsessionlock.ExtSessionLockSurfaceV1
21 server *Server
22 surface *surface
23 output *output
24 id ports.WindowID
25 serials []lockConfigure
26 ack lockConfigure
27 mapped, closed bool
28 // onUnmap is a display-owner lifecycle notification, like Resource.OnDestroy.
29 // The lock owner must remain protected; this never requests an unlock.
30 onUnmap func(*lockSurface)
31}
32
33// newLockSurface only assigns the surface role. The caller validates ownership,
34// admission, output lifetime and duplicate outputs before calling it. No global
35// is registered and no locked/finished event is sent here.
36func (s *Server) newLockSurface(r *extsessionlock.ExtSessionLockV1, id uint32, surf *surface, out *output, onUnmap func(*lockSurface)) (*lockSurface, error) {
37 if surf.kind != roleNone {
38 r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorRole), "surface already has a role")
39 return nil, nil
40 }
41 if surf.current != nil || surf.next.buffer != nil || surf.bufferCommitted {
42 r.PostError(uint32(extsessionlock.ExtSessionLockV1ErrorAlreadyConstructed), "surface has an attached or previously committed buffer")
43 return nil, nil
44 }
45 l := &lockSurface{server: s, surface: surf, output: out, id: s.nextWindow, onUnmap: onUnmap}
46 res, err := extsessionlock.NewExtSessionLockSurfaceV1(r.Client(), r.Version(), id, l)
47 if err != nil {
48 return nil, err
49 }
50 l.resource = res
51 s.nextWindow++
52 surf.kind, surf.lock, surf.role = roleSessionLock, l, l.commit
53 if s.lockSurfaces == nil {
54 s.lockSurfaces = make(map[ports.WindowID]*lockSurface)
55 }
56 s.lockSurfaces[l.id] = l
57 s.lockSurfaceChanged()
58 res.OnDestroy = l.unmap
59 l.sendConfigure()
60 return l, nil
61}
62
63// sendConfigure uses exact logical dimensions, not the physical output mode.
64// Output inventory changes are routed here by the lock owner.
65func (l *lockSurface) sendConfigure() {
66 if l.closed || l.output == nil || !l.resource.Alive() {
67 return
68 }
69 l.server.serial++
70 // Serial zero is reserved internally as "no ACK".
71 if l.server.serial == 0 {
72 l.server.serial++
73 }
74 c := lockConfigure{serial: l.server.serial, width: l.output.place.Width, height: l.output.place.Height}
75 l.serials = append(l.serials, c)
76 l.resource.SendConfigure(c.serial, uint32(c.width), uint32(c.height))
77}
78
79func (l *lockSurface) AckConfigure(r *extsessionlock.ExtSessionLockSurfaceV1, serial uint32) {
80 for i, c := range l.serials {
81 if c.serial == serial {
82 l.ack = c
83 l.serials = l.serials[i+1:]
84 return
85 }
86 }
87 r.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorInvalidSerial), "unknown or consumed configure serial")
88}
89
90func (*lockSurface) Destroy(*extsessionlock.ExtSessionLockSurfaceV1) {}
91
92// lockSurfaceChanged publishes validated placements for the admitted owner.
93// Role commits never release protection or certify physical presentation.
94func (s *Server) lockSurfaceChanged() {
95 l := s.sessionLock
96 if l == nil {
97 return
98 }
99 placements := make([]ports.LockSurfacePlacement, 0, len(l.surfaces))
100 // Before locked, only compositor black is displayed. Protocol configure
101 // and buffer commits may still prepare the UI while proofs are pending.
102 if l.locked {
103 for _, surface := range l.surfaces {
104 if surface.mapped && !surface.closed && surface.output != nil && surface.resource.Alive() {
105 p := surface.output.place
106 c := surface.surface.content
107 if c.LogicalW == p.Width && c.LogicalH == p.Height {
108 placements = append(placements, ports.LockSurfacePlacement{ID: surface.id, Output: surface.output.name(), Width: p.Width, Height: p.Height})
109 }
110 }
111 }
112 }
113 slices.SortFunc(placements, func(a, b ports.LockSurfacePlacement) int { return cmp.Compare(a.ID, b.ID) })
114 s.emit(ports.SessionLockChanged{State: l.state, Surfaces: placements})
115}
116
117// lockBuffer is the buffer after all earlier captured commits, not merely the
118// currently applied buffer. A queued attach is sticky for subsequent commits.
119func (s *surface) lockBuffer() *wayland.Buffer {
120 if s.next.attached {
121 return s.next.buffer
122 }
123 for i := len(s.queue) - 1; i >= 0; i-- {
124 if s.queue[i].attached {
125 return s.queue[i].buffer
126 }
127 }
128 return s.current
129}
130
131// checkCommit runs before capture or fence acquisition. Waiting behind FIFO or
132// an acquire fence must not let a later ACK legalize this commit.
133func (l *lockSurface) checkCommit() bool {
134 s := l.surface
135 g := &surface{server: s.server, wl: s.wl, viewport: s.viewport, bufferScale: max(s.next.scale, 1), transform: s.next.transform}
136 if v := s.viewport; v != nil {
137 g.committedViewport = viewportState{destW: v.pendingW, destH: v.pendingH, dest: v.pendingSet, src: v.pendingSrc, crop: v.pendingCrop}
138 }
139 return l.checkGeometry(l.ack, s.lockBuffer(), g)
140}
141
142func (l *lockSurface) checkGeometry(ack lockConfigure, b *wayland.Buffer, g *surface) bool {
143 if ack.serial == 0 {
144 l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorCommitBeforeFirstAck), "commit before first configure ACK")
145 return false
146 }
147 if b == nil {
148 l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorNullBuffer), "null lock buffer")
149 return false
150 }
151 state := l.server.buffers[b.Resource]
152 var bw, bh int
153 if state != nil {
154 bw, bh = state.size()
155 } else if sameBuffer(b, l.surface.current) && l.surface.has {
156 // Destroying an applied wl_buffer does not detach its content. The
157 // retained physical size remains authoritative for no-attach commits.
158 bw, bh = l.surface.content.Width, l.surface.content.Height
159 } else {
160 l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "unknown lock buffer dimensions")
161 return false
162 }
163 if !g.validateViewport(bw, bh) {
164 return false
165 }
166 w, h := g.logicalSize(bw, bh)
167 if w != ack.width || h != ack.height {
168 l.resource.PostError(uint32(extsessionlock.ExtSessionLockSurfaceV1ErrorDimensionsMismatch), "lock buffer does not match acknowledged logical dimensions")
169 return false
170 }
171 return true
172}
173
174// checkCaptured validates only captured ACK and geometry, never live requests.
175func (l *lockSurface) checkCaptured(u *update) bool {
176 s := l.surface
177 b := s.current
178 if u.attached {
179 b = u.buffer
180 }
181 g := &surface{server: s.server, wl: s.wl, viewport: u.vp, bufferScale: max(u.scale, 1), transform: u.transform}
182 if u.vp != nil {
183 g.committedViewport = viewportState{destW: u.vpW, destH: u.vpH, dest: u.vpSet, src: u.vpSrc, crop: u.vpCrop}
184 }
185 return l.checkGeometry(u.lockAck, b, g)
186}
187
188func (l *lockSurface) commit(buffer bool) {
189 if l.surface.destroyed || !buffer {
190 l.unmap()
191 return
192 }
193 if !l.closed {
194 l.mapped = true
195 l.server.lockSurfaceChanged()
196 }
197}
198
199// dropLockDeps retires the synchronized child prefixes captured by this root.
200// Incoming graph links keep retired nodes readable until the root releases its
201// refs. Later, uncaptured child commits remain owned by their own queues.
202func (s *surface) dropLockDeps() {
203 prefixes := make(map[*surface]int)
204 var visit func(*update)
205 seen := make(map[*update]bool)
206 visit = func(u *update) {
207 if u == nil || u.retired || seen[u] {
208 return
209 }
210 seen[u] = true
211 visit(u.prev)
212 for _, dep := range u.deps {
213 visit(dep)
214 if dep.retired || dep.owner == s {
215 continue
216 }
217 for i, queued := range dep.owner.queue {
218 if queued == dep {
219 prefixes[dep.owner] = max(prefixes[dep.owner], i+1)
220 break
221 }
222 }
223 }
224 }
225 for _, u := range s.queue {
226 visit(u)
227 }
228 for child, n := range prefixes {
229 child.dropQueuePrefix(n)
230 }
231}
232
233// unmap also handles output loss/owner teardown. Role identity is permanent.
234// Drop queued graphs through their normal retirement path and release current
235// content while the mapped identity is still available to the hold machinery.
236func (l *lockSurface) unmap() {
237 if l.closed {
238 return
239 }
240 s := l.surface
241 // Send leaves while outgoing role identity remains resolvable. Destroying
242 // the lock role may leave its wl_surface and seat resources alive.
243 if l.server.seat.focused == l.id {
244 l.server.changeFocus(0)
245 }
246 if l.server.seat.pointerFocus == l.id {
247 l.server.changePointerFocus(0, 0, 0)
248 }
249 s.dropQueue()
250 s.dropSync(s.next.sync)
251 s.next.sync = nil
252 if s.current != nil {
253 l.server.releaseBuffer(s, s.current, s.hold)
254 }
255 s.current, s.next.buffer, s.hold = nil, nil, syncHold{}
256 s.next.attached = false
257 s.content, s.has = ports.SurfaceContent{}, false
258 l.mapped, l.closed = false, true
259 l.serials = nil
260 delete(l.server.lockSurfaces, l.id)
261 s.lock, s.role = nil, nil
262 l.server.emitContent(ports.SurfaceContent{ID: l.id}, damage{full: true})
263 l.server.lockSurfaceChanged()
264 if l.onUnmap != nil {
265 l.onUnmap(l)
266 }
267}