1package uidriver
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "unicode/utf8"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"
11)
12
13type envelope struct {
14 Version int `json:"version"`
15 ID uint64 `json:"id"`
16 Result any `json:"result,omitempty"`
17 Error *responseError `json:"error,omitempty"`
18}
19
20type responseError struct {
21 Code ports.UIErrorCode `json:"code"`
22 Message string `json:"message"`
23 Accepted bool `json:"accepted"`
24 ActionID uint64 `json:"action_id,omitempty"`
25}
26
27// Ready is connection discovery, never an attachment registry.
28type Ready struct {
29 Attachment string `json:"attachment"`
30 Generation uint64 `json:"generation"`
31 Control bool `json:"control"`
32 Status ports.UIPresentationStatus `json:"status"`
33}
34
35type contextResponse struct {
36 Attachment string `json:"attachment"`
37 Generation uint64 `json:"generation"`
38 Session sessionResponse `json:"session"`
39 Focus focusResponse `json:"focus"`
40 Epoch uint64 `json:"output_epoch"`
41 State uint64 `json:"output_state"`
42 ViewRevision uint64 `json:"view_revision"`
43 ViewPublication uint64 `json:"view_publication"`
44 Status ports.UIPresentationStatus `json:"status"`
45}
46
47type sessionResponse struct {
48 LifecycleID string `json:"lifecycle_id"`
49 SessionName string `json:"session_name"`
50}
51
52type focusResponse struct {
53 TabID domain.TabStableID `json:"tab_id"`
54 PaneID domain.PaneStableID `json:"pane_id"`
55}
56
57type cursorResponse struct {
58 Row int `json:"row"`
59 Column int `json:"column"`
60 Visible bool `json:"visible"`
61 Style int `json:"style"`
62 StyleSet bool `json:"style_set"`
63}
64
65type colorKind string
66
67const (
68 colorDefault colorKind = "default"
69 colorIndexed colorKind = "indexed"
70 colorRGB colorKind = "rgb"
71)
72
73type colorResponse struct {
74 Kind colorKind `json:"kind"`
75 Index uint8 `json:"index"`
76 R uint8 `json:"r"`
77 G uint8 `json:"g"`
78 B uint8 `json:"b"`
79}
80
81type styleResponse struct {
82 Foreground colorResponse `json:"foreground"`
83 Background colorResponse `json:"background"`
84 Bold bool `json:"bold"`
85 Dim bool `json:"dim"`
86 Italic bool `json:"italic"`
87 Underline bool `json:"underline"`
88 UnderlineStyle uint8 `json:"underline_style"`
89 Blink bool `json:"blink"`
90 Inverse bool `json:"inverse"`
91 Strikethrough bool `json:"strikethrough"`
92}
93
94type cellResponse struct {
95 Text string `json:"text"`
96 Width int `json:"width"`
97 Continuation bool `json:"continuation"`
98 Style styleResponse `json:"style"`
99}
100
101type captureResponse struct {
102 Revision uint64 `json:"revision"`
103 Context contextResponse `json:"context"`
104 Columns int `json:"columns"`
105 Rows int `json:"rows"`
106 Cursor cursorResponse `json:"cursor"`
107 Text *string `json:"text,omitempty"`
108 Cells []cellResponse `json:"cells,omitempty"`
109}
110
111type actionResponse struct {
112 ActionID uint64 `json:"action_id"`
113 Accepted bool `json:"accepted"`
114 Status ports.UIActionStatus `json:"status"`
115 Revision uint64 `json:"revision"`
116 Context contextResponse `json:"context"`
117}
118
119type waitResponse struct {
120 ActionID uint64 `json:"action_id,omitempty"`
121 ActionStatus ports.UIActionStatus `json:"action_status,omitempty"`
122 Revision uint64 `json:"revision"`
123 Context contextResponse `json:"context"`
124}
125
126func publicContext(value ports.UIContext) contextResponse {
127 var lifecycleID string
128 if value.Route.Target.LifecycleID != (domain.SessionLifecycleID{}) {
129 lifecycleID = value.Route.Target.LifecycleID.String()
130 }
131 return contextResponse{Attachment: value.AttachmentHandle, Generation: value.Generation, Session: sessionResponse{LifecycleID: lifecycleID, SessionName: value.Route.Target.SessionName}, Focus: focusResponse{TabID: value.TabID, PaneID: value.FocusedPaneID}, Epoch: value.OutputEpoch, State: value.OutputState, ViewRevision: value.ViewRevision, ViewPublication: value.ViewPublication, Status: value.Status}
132}
133
134func publicColor(value ports.UIColor) colorResponse {
135 result := colorResponse{Kind: colorDefault}
136 switch value.Kind {
137 case ports.UIColorIndexed:
138 result.Kind = colorIndexed
139 result.Index = value.Index
140 case ports.UIColorRGB:
141 result.Kind = colorRGB
142 result.R, result.G, result.B = value.R, value.G, value.B
143 }
144 return result
145}
146
147func publicCapture(snapshot ports.UISnapshot, format captureFormat) (captureResponse, error) {
148 result := captureResponse{}
149 if snapshot.Columns < 1 || snapshot.Rows < 1 || snapshot.Columns > maxColumns || snapshot.Rows > maxRows {
150 return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
151 }
152 if len(snapshot.Cells) != snapshot.Columns*snapshot.Rows {
153 return result, ports.ErrUIUnavailable
154 }
155 if !captureFitsResponse(snapshot, format) {
156 return result, &ports.UIError{Code: ports.UIErrCaptureTooLarge}
157 }
158 result = captureResponse{Revision: snapshot.Revision, Context: publicContext(snapshot.Context), Columns: snapshot.Columns, Rows: snapshot.Rows, Cursor: cursorResponse{Row: snapshot.Cursor.Row, Column: snapshot.Cursor.Column, Visible: snapshot.Cursor.Visible, Style: snapshot.Cursor.Style, StyleSet: snapshot.Cursor.StyleSet}}
159 if format == formatText || format == formatBoth {
160 var text strings.Builder
161 for row := 0; row < snapshot.Rows; row++ {
162 if row > 0 {
163 text.WriteByte('\n')
164 }
165 for _, cell := range snapshot.Cells[row*snapshot.Columns : (row+1)*snapshot.Columns] {
166 if cell.Continuation {
167 continue
168 }
169 if cell.Text == "" {
170 text.WriteByte(' ')
171 } else {
172 text.WriteString(cell.Text)
173 }
174 }
175 }
176 value := text.String()
177 result.Text = &value
178 }
179 if format == formatCells || format == formatBoth {
180 result.Cells = make([]cellResponse, len(snapshot.Cells))
181 for i, cell := range snapshot.Cells {
182 s := cell.Style
183 result.Cells[i] = cellResponse{Text: cell.Text, Width: cell.Width, Continuation: cell.Continuation, Style: styleResponse{Foreground: publicColor(s.Foreground), Background: publicColor(s.Background), Bold: s.Bold, Dim: s.Dim, Italic: s.Italic, Underline: s.Underline, UnderlineStyle: s.UnderlineStyle, Blink: s.Blink, Inverse: s.Inverse, Strikethrough: s.Strikethrough}}
184 }
185 }
186 return result, nil
187}
188
189// captureFitsResponse rejects a malicious or corrupted snapshot before
190// constructing a potentially large response object. The estimate is
191// intentionally conservative for JSON string escaping and cell style fields.
192func captureFitsResponse(snapshot ports.UISnapshot, format captureFormat) bool {
193 const captureOverhead = 4096
194 const cellOverhead = 320
195 size := captureOverhead
196 for _, cell := range snapshot.Cells {
197 if !utf8.ValidString(cell.Text) {
198 return false
199 }
200 if format == formatCells || format == formatBoth {
201 if len(cell.Text) > maxResponseBytes || size > maxResponseBytes-len(cell.Text)-cellOverhead {
202 return false
203 }
204 size += len(cell.Text) + cellOverhead
205 }
206 if (format == formatText || format == formatBoth) && !cell.Continuation {
207 if size > maxResponseBytes-len(cell.Text)-1 {
208 return false
209 }
210 size += len(cell.Text) + 1
211 }
212 }
213 return size <= maxResponseBytes
214}
215
216// Never serialize arbitrary error text: lower layers may include endpoints,
217// input, screen content, or environment. The public code is the safe message.
218func errorEnvelope(id uint64, err error) envelope {
219 failure := responseError{Code: ports.UIErrUnavailable}
220 var uiErr *ports.UIError
221 if errors.As(err, &uiErr) {
222 failure.Code = uiErr.Code
223 failure.Accepted = uiErr.Accepted
224 failure.ActionID = uiErr.ActionID
225 } else if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
226 failure.Code = ports.UIErrTimeout
227 }
228 failure.Message = string(failure.Code)
229 return envelope{Version: apiVersion, ID: id, Error: &failure}
230}