1// Package daemon holds vev's server-side session multiplexer use case: the
2// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3// and VT screen, and the per-client debounced render coordinator.
4package daemon
5
6import (
7 "errors"
8 "os"
9
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"
12 scopy "github.com/bnema/vev/internal/usecase/copy"
13)
14
15// maxImagePushSize independently caps an accepted ImagePush payload,
16// defending against an old or foreign client even though the client is
17// expected to enforce the same cap before sending. Kept at 1 MiB so one
18// ImagePush fits the datagram transport's fragmented payload ceiling.
19const maxImagePushSize = 1 << 20 // 1 MiB
20
21// Bracketed-paste markers used to wrap the injected clip path when the
22// focused pane has DEC private mode 2004 enabled. Mirrors the client's
23// pasteOpenMarker/pasteCloseMarker (internal/usecase/client), kept as a
24// separate copy since the daemon has no dependency on the client package.
25var (
26 clipPasteOpenMarker = []byte("\x1b[200~")
27 clipPasteCloseMarker = []byte("\x1b[201~")
28)
29
30func (d *Daemon) handleSequencedImagePushForAttachment(effect *attachmentEffect, _ uint64, ip protocol.ImagePush) {
31 if !effect.current() {
32 return
33 }
34 d.handleImagePushForAttachment(effect, ip)
35}
36
37func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip protocol.ImagePush) {
38 if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {
39 return
40 }
41 // A client-owned picker owns the user input for this attachment: a
42 // clipboard path must not reach the session while it is open, exactly
43 // like a keystroke. The client re-sends after the interaction closes.
44 if effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() {
45 return
46 }
47 path, err := d.writeClipboardImageForAttachment(effect, ip)
48 if err != nil {
49 d.log.Error("writing clipboard image failed", "err", err)
50 return
51 }
52 if !effect.current() {
53 _ = os.Remove(path)
54 return
55 }
56 d.injectClipboardPathForAttachment(effect, path)
57}
58
59// handleImagePush writes an ImagePush's bytes to a temp file and injects the
60// file's path into the session's focused pane, as if typed/pasted there.
61// Oversized or empty payloads are rejected without panicking. A write
62// failure is logged and the push is dropped: the client has already
63// consumed the Ctrl+V keystroke that triggered it, so there is nothing left
64// to re-trigger from here.
65func (d *Daemon) handleImagePush(sess *session, ip protocol.ImagePush) {
66 if len(ip.Data) == 0 {
67 return
68 }
69 if len(ip.Data) > maxImagePushSize {
70 d.log.Warn("rejected oversized clipboard image push", "size", len(ip.Data), "cap", maxImagePushSize)
71 return
72 }
73 path, err := d.writeClipboardImage(sess, ip)
74 if err != nil {
75 d.log.Error("writing clipboard image failed", "err", err)
76 return
77 }
78 d.injectClipboardPath(sess, path)
79}
80
81// writeClipboardImage writes ip's bytes to a new 0600 temp file and records
82// the path on sess for best-effort cleanup at session end (killSession).
83func (d *Daemon) writeClipboardImage(sess *session, ip protocol.ImagePush) (string, error) {
84 path, err := d.createClipboardImage(ip)
85 if err != nil {
86 return "", err
87 }
88 sess.mu.Lock()
89 sess.clipFiles = append(sess.clipFiles, path)
90 sess.mu.Unlock()
91 return path, nil
92}
93
94func (d *Daemon) writeClipboardImageForAttachment(effect *attachmentEffect, ip protocol.ImagePush) (string, error) {
95 path, err := d.createClipboardImage(ip)
96 if err != nil {
97 return "", err
98 }
99 sess := effect.sess
100 if sess == nil {
101 _ = os.Remove(path)
102 return "", errAttachmentTransition
103 }
104 sess.mu.Lock()
105 if !effect.currentSessionLocked() {
106 sess.mu.Unlock()
107 _ = os.Remove(path)
108 return "", errAttachmentTransition
109 }
110 sess.clipFiles = append(sess.clipFiles, path)
111 sess.mu.Unlock()
112 return path, nil
113}
114
115func (d *Daemon) createClipboardImage(ip protocol.ImagePush) (string, error) {
116 dir := d.tempDir
117 if dir == "" {
118 dir = os.TempDir()
119 }
120 f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
121 if err != nil {
122 return "", err
123 }
124 path := f.Name()
125 if _, err := f.Write(ip.Data); err != nil {
126 _ = f.Close()
127 _ = os.Remove(path)
128 return "", err
129 }
130 if err := f.Close(); err != nil {
131 _ = os.Remove(path)
132 return "", err
133 }
134 return path, nil
135}
136
137// injectClipboardPath writes path's text into sess's focused pane, through
138// the same PTY write path as ordinary input, wrapped in bracketed-paste
139// markers iff the pane's Screen reports bracketed-paste mode enabled.
140func (d *Daemon) injectClipboardPath(sess *session, path string) {
141 d.injectClipboardPathToTarget(sess, path, nil)
142}
143
144func (d *Daemon) injectClipboardPathForAttachment(effect *attachmentEffect, path string) {
145 sess := effect.sess
146 if sess == nil || !effect.current() {
147 return
148 }
149 d.injectClipboardPathToTarget(sess, path, effect)
150}
151
152func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect *attachmentEffect) {
153 var ac *attachedClient
154 if effect != nil {
155 ac = effect.ac
156 }
157 var tb *tab
158 if ac != nil {
159 tb = sess.tabForAttachment(ac)
160 } else {
161 tb = sess.firstTab()
162 }
163 if tb == nil {
164 return
165 }
166 tb.mu.Lock()
167 p := tb.terminalTargetLocked()
168 tb.mu.Unlock()
169 if p == nil {
170 return
171 }
172
173 p.mu.Lock()
174 bracketed := p.screen.BracketedPasteMode()
175 p.mu.Unlock()
176
177 data := []byte(path)
178 if bracketed {
179 wrapped := make([]byte, 0, len(clipPasteOpenMarker)+len(data)+len(clipPasteCloseMarker))
180 wrapped = append(wrapped, clipPasteOpenMarker...)
181 wrapped = append(wrapped, data...)
182 wrapped = append(wrapped, clipPasteCloseMarker...)
183 data = wrapped
184 }
185 if effect != nil && !effect.current() {
186 return
187 }
188 // The injection point is the last gate: the interaction may have opened
189 // between admission and here.
190 if effect != nil && effect.ac != nil && effect.ac.overlays != nil && effect.ac.overlays.pickerClientActive() {
191 return
192 }
193 d.writeToPane(sess, p, data)
194}
195
196// clipboardExt maps an ImagePush MIME type to a filename extension. Unknown
197// types fall back to "bin" rather than rejecting the push: the daemon copies
198// bytes as-is regardless (see the design doc's "no image conversion" scope
199// decision).
200func clipboardExt(mime string) string {
201 switch mime {
202 case "image/png":
203 return "png"
204 case "image/jpeg":
205 return "jpg"
206 case "image/webp":
207 return "webp"
208 case "image/gif":
209 return "gif"
210 default:
211 return "bin"
212 }
213}
214
215type clipboardForward struct {
216 owner paneEffectLease
217 token attachmentCapability
218 seq []byte
219}
220
221// forwardClipboardAsync re-emits an app-originated OSC 52 clipboard set
222// request (already captured off the pane's screen while ptyReader held pane.mu,
223// then handed here after that lock is released) to the session's attached
224// client, if any. Invalid base64 or an oversized decoded payload is dropped
225// silently, matching copy mode's own cap (scopy.OSC52MaxPayloadBytes). The
226// caller must not hold pane.mu, tab.mu, or session.mu.
227//
228// Requests are queued on the session and drained by one worker so clipboard
229// writes keep arrival order without making the PTY reader wait on client I/O.
230func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
231 seq := scopy.OSC52FromBase64(b64)
232 if seq == nil || !owner.Current() {
233 return
234 }
235 sess := owner.owner.session
236
237 sess.mu.Lock()
238 if sess.ctx != nil {
239 select {
240 case <-sess.ctx.Done():
241 sess.mu.Unlock()
242 return
243 default:
244 }
245 }
246 attachments := sess.snapshotAttachmentsLocked()
247 sess.mu.Unlock()
248
249 items := make([]clipboardForward, 0, len(attachments))
250 for _, ac := range attachments {
251 transport := ac.transport()
252 token := sess.captureAttachmentCapability(ac, transport)
253 if token.current() {
254 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
255 }
256 }
257 if len(items) == 0 {
258 return
259 }
260 sess.mu.Lock()
261 sess.clipboardQueue = append(sess.clipboardQueue, items...)
262 if !sess.clipboardWorkerRunning {
263 sess.clipboardWorkerRunning = true
264 go d.clipboardWorker(sess)
265 }
266 sess.mu.Unlock()
267}
268
269func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffect) (ports.ServerConnection, error) {
270 token := item.token
271 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
272 return token.transport.transport, errAttachmentTransition
273 }
274 expected := token.transport
275 owned := expected.transport.Capabilities().OwnedSynchronousSend
276 send := func() error {
277 ac := token.ac
278 ac.sendMu.Lock()
279 defer ac.sendMu.Unlock()
280 if ticket.ended.Load() || !ac.transportSnapshotCurrent(expected) {
281 return errAttachmentTransition
282 }
283 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
284 return errAttachmentTransition
285 }
286 ac.output.lockView()
287 defer ac.output.unlockView()
288 output, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
289 if err == nil {
290 if owned {
291 err = expected.transport.SendOutputSynchronous(output)
292 } else {
293 err = expected.transport.SendOutput(output)
294 }
295 }
296 if err != nil {
297 ticket.reportTransportFailure(expected)
298 }
299 ticket.endTransportSend()
300 return err
301 }
302 if owned {
303 return expected.transport, send()
304 }
305 return d.boundedSendWith(expected.transport, send)
306}
307
308// beginClipboardOwnerSend validates the source pane generation after sendMu
309// admission and marks the transport interval while ownership publication is
310// excluded. Publication therefore orders either before this check (the send is
311// dropped) or after transport admission (the send belongs to the old owner).
312func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffect, expected transportSnapshot) bool {
313 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
314 return false
315 }
316 owner := lease.owner
317 if owner.floatingSlotGeneration != 0 {
318 owner.tab.mu.Lock()
319 defer owner.tab.mu.Unlock()
320 }
321 lease.pane.mu.Lock()
322 defer lease.pane.mu.Unlock()
323 if lease.pane.owner.Load() != owner {
324 return false
325 }
326 if owner.floatingSlotGeneration != 0 &&
327 (owner.tab.floating.pane != lease.pane || owner.tab.floating.generation != owner.floatingSlotGeneration ||
328 (owner.tab.floating.state != floatingHidden && owner.tab.floating.state != floatingVisible)) {
329 return false
330 }
331 return ticket.beginTransportSend(expected)
332}
333
334func (d *Daemon) clipboardWorker(sess *session) {
335 for {
336 item, ok := nextClipboardForward(sess)
337 if !ok {
338 return
339 }
340 if !item.owner.Current() {
341 continue
342 }
343 ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
344 if !admitted {
345 continue
346 }
347 failed, err := d.boundedSendClipboardForward(item, ticket)
348 if errors.Is(err, errSendTimedOut) {
349 _ = item.token.ac.closeCapturedTransport(failed)
350 }
351 ticket.End()
352 if err != nil && item.owner.Current() {
353 d.detachOnAttachmentSendError(item.token, failed)
354 }
355 }
356}
357
358func nextClipboardForward(sess *session) (clipboardForward, bool) {
359 sess.mu.Lock()
360 defer sess.mu.Unlock()
361 if sess.ctx != nil {
362 select {
363 case <-sess.ctx.Done():
364 sess.clipboardQueue = nil
365 sess.clipboardWorkerRunning = false
366 return clipboardForward{}, false
367 default:
368 }
369 }
370 if len(sess.clipboardQueue) == 0 {
371 sess.clipboardWorkerRunning = false
372 return clipboardForward{}, false
373 }
374 item := sess.clipboardQueue[0]
375 copy(sess.clipboardQueue, sess.clipboardQueue[1:])
376 var zero clipboardForward
377 sess.clipboardQueue[len(sess.clipboardQueue)-1] = zero
378 sess.clipboardQueue = sess.clipboardQueue[:len(sess.clipboardQueue)-1]
379 return item, true
380}