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