fix(remote): address follow-up picker review findings
- commit
- fe225948dcc40d8b274d794ba155e12e64147304
- author
- brice · · 2026-08-05 13:24 UTC
- parent
- 9aa4936
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +387 -305
M internal/ports/frame.go +43 -1
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 4 | 9 | ||
| 5 | 10 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 6 | 11 | // first committed publication. | |
| @@ -55,3 +60,40 @@ type Frame struct { | |||
| 55 | 60 | Type MsgType | |
| 56 | 61 | Payload []byte | |
| 57 | 62 | } | |
| 63 | + | ||
| 64 | + | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |
| 65 | + | const RemotePreviewSchemaVersion uint16 = 1 | |
| 66 | + | ||
| 67 | + | // RemotePreviewStatus is a closed response taxonomy. Terminal contents are | |
| 68 | + | // never carried in an error response. | |
| 69 | + | type RemotePreviewStatus uint8 | |
| 70 | + | ||
| 71 | + | const ( | |
| 72 | + | RemotePreviewOK RemotePreviewStatus = iota | |
| 73 | + | RemotePreviewUnavailable | |
| 74 | + | RemotePreviewNoSuchTarget | |
| 75 | + | RemotePreviewStale | |
| 76 | + | RemotePreviewMalformed | |
| 77 | + | RemotePreviewTooLarge | |
| 78 | + | ) | |
| 79 | + | ||
| 80 | + | // RemotePreviewRequest asks the owning daemon for an in-memory live viewport. | |
| 81 | + | type RemotePreviewRequest struct { | |
| 82 | + | Version uint16 | |
| 83 | + | Target domain.RemoteSessionTarget | |
| 84 | + | Width uint16 | |
| 85 | + | Height uint16 | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // RemotePreview is a bounded row-major styled-cell viewport. It is a | |
| 89 | + | // process-memory DTO and is never persisted, logged, or traced. | |
| 90 | + | type RemotePreview struct { | |
| 91 | + | Version uint16 | |
| 92 | + | Status RemotePreviewStatus | |
| 93 | + | LifecycleID domain.SessionLifecycleID | |
| 94 | + | TabID domain.TabStableID | |
| 95 | + | Revision uint64 | |
| 96 | + | Width uint16 | |
| 97 | + | Height uint16 | |
| 98 | + | Cells []renderer.Cell | |
| 99 | + | } | |
| @@ -1,6 +1,11 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | - | import "time" | 3 | + | import ( |
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 4 | 9 | ||||
| 5 | // HandshakeTimeout bounds every transport handshake from connect through the | 10 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 6 | // first committed publication. | 11 | // first committed publication. | ||
| @@ -55,3 +60,40 @@ type Frame struct { | |||||
| 55 | Type MsgType | 60 | Type MsgType | ||
| 56 | Payload []byte | 61 | Payload []byte | ||
| 57 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |||
| 65 | + | const RemotePreviewSchemaVersion uint16 = 1 | |||
| 66 | + | ||||
| 67 | + | // RemotePreviewStatus is a closed response taxonomy. Terminal contents are | |||
| 68 | + | // never carried in an error response. | |||
| 69 | + | type RemotePreviewStatus uint8 | |||
| 70 | + | ||||
| 71 | + | const ( | |||
| 72 | + | RemotePreviewOK RemotePreviewStatus = iota | |||
| 73 | + | RemotePreviewUnavailable | |||
| 74 | + | RemotePreviewNoSuchTarget | |||
| 75 | + | RemotePreviewStale | |||
| 76 | + | RemotePreviewMalformed | |||
| 77 | + | RemotePreviewTooLarge | |||
| 78 | + | ) | |||
| 79 | + | ||||
| 80 | + | // RemotePreviewRequest asks the owning daemon for an in-memory live viewport. | |||
| 81 | + | type RemotePreviewRequest struct { | |||
| 82 | + | Version uint16 | |||
| 83 | + | Target domain.RemoteSessionTarget | |||
| 84 | + | Width uint16 | |||
| 85 | + | Height uint16 | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // RemotePreview is a bounded row-major styled-cell viewport. It is a | |||
| 89 | + | // process-memory DTO and is never persisted, logged, or traced. | |||
| 90 | + | type RemotePreview struct { | |||
| 91 | + | Version uint16 | |||
| 92 | + | Status RemotePreviewStatus | |||
| 93 | + | LifecycleID domain.SessionLifecycleID | |||
| 94 | + | TabID domain.TabStableID | |||
| 95 | + | Revision uint64 | |||
| 96 | + | Width uint16 | |||
| 97 | + | Height uint16 | |||
| 98 | + | Cells []renderer.Cell | |||
| 99 | + | } | |||
M internal/ports/remote_preview.go +2 -299
| @@ -3,15 +3,13 @@ package ports | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "unicode" | |
| 6 | 7 | "unicode/utf8" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | - | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |
| 13 | - | const RemotePreviewSchemaVersion uint16 = 1 | |
| 14 | - | ||
| 15 | 13 | const ( | |
| 16 | 14 | RemotePreviewMaxWidth = 256 | |
| 17 | 15 | RemotePreviewMaxHeight = 128 | |
| @@ -19,40 +17,6 @@ const ( | |||
| 19 | 17 | RemotePreviewMaxBytes = 1 << 20 | |
| 20 | 18 | ) | |
| 21 | 19 | ||
| 22 | - | // RemotePreviewStatus is a closed response taxonomy. Terminal contents are | |
| 23 | - | // never carried in an error response. | |
| 24 | - | type RemotePreviewStatus uint8 | |
| 25 | - | ||
| 26 | - | const ( | |
| 27 | - | RemotePreviewOK RemotePreviewStatus = iota | |
| 28 | - | RemotePreviewUnavailable | |
| 29 | - | RemotePreviewNoSuchTarget | |
| 30 | - | RemotePreviewStale | |
| 31 | - | RemotePreviewMalformed | |
| 32 | - | RemotePreviewTooLarge | |
| 33 | - | ) | |
| 34 | - | ||
| 35 | - | // RemotePreviewRequest asks the owning daemon for an in-memory live viewport. | |
| 36 | - | type RemotePreviewRequest struct { | |
| 37 | - | Version uint16 | |
| 38 | - | Target domain.RemoteSessionTarget | |
| 39 | - | Width uint16 | |
| 40 | - | Height uint16 | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // RemotePreview is a bounded row-major styled-cell viewport. It is a | |
| 44 | - | // process-memory DTO and is never persisted, logged, or traced. | |
| 45 | - | type RemotePreview struct { | |
| 46 | - | Version uint16 | |
| 47 | - | Status RemotePreviewStatus | |
| 48 | - | LifecycleID domain.SessionLifecycleID | |
| 49 | - | TabID domain.TabStableID | |
| 50 | - | Revision uint64 | |
| 51 | - | Width uint16 | |
| 52 | - | Height uint16 | |
| 53 | - | Cells []renderer.Cell | |
| 54 | - | } | |
| 55 | - | ||
| 56 | 20 | var ( | |
| 57 | 21 | ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request") | |
| 58 | 22 | ErrInvalidRemotePreview = errors.New("ports: invalid remote preview") | |
| @@ -101,7 +65,7 @@ func ValidateRemotePreview(preview RemotePreview) error { | |||
| 101 | 65 | } | |
| 102 | 66 | width := int(preview.Width) | |
| 103 | 67 | for i, cell := range preview.Cells { | |
| 104 | - | if !utf8.ValidRune(cell.Rune) || !validRemotePreviewStyle(cell.Style) { | |
| 68 | + | if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validRemotePreviewStyle(cell.Style) { | |
| 105 | 69 | return ErrInvalidRemotePreview | |
| 106 | 70 | } | |
| 107 | 71 | rowStart := (i / width) * width | |
| @@ -125,267 +89,6 @@ func validRemotePreviewStyle(style renderer.Style) bool { | |||
| 125 | 89 | style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16 | |
| 126 | 90 | } | |
| 127 | 91 | ||
| 128 | - | func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) } | |
| 129 | - | func getInt16(r *payloadReader) (int, error) { | |
| 130 | - | n, err := r.getUint16() | |
| 131 | - | return int(int16(n)), err | |
| 132 | - | } | |
| 133 | - | func putRGB(w *payloadWriter, c renderer.RGB) { w.putUint8(c.R); w.putUint8(c.G); w.putUint8(c.B) } | |
| 134 | - | func getRGB(r *payloadReader) (renderer.RGB, error) { | |
| 135 | - | red, err := r.getUint8() | |
| 136 | - | if err != nil { | |
| 137 | - | return renderer.RGB{}, err | |
| 138 | - | } | |
| 139 | - | green, err := r.getUint8() | |
| 140 | - | if err != nil { | |
| 141 | - | return renderer.RGB{}, err | |
| 142 | - | } | |
| 143 | - | blue, err := r.getUint8() | |
| 144 | - | if err != nil { | |
| 145 | - | return renderer.RGB{}, err | |
| 146 | - | } | |
| 147 | - | return renderer.RGB{R: red, G: green, B: blue}, nil | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func putPreviewStyle(w *payloadWriter, s renderer.Style) { | |
| 151 | - | var flags uint8 | |
| 152 | - | if s.Bold { | |
| 153 | - | flags |= 1 << 0 | |
| 154 | - | } | |
| 155 | - | if s.Italic { | |
| 156 | - | flags |= 1 << 1 | |
| 157 | - | } | |
| 158 | - | if s.Inverse { | |
| 159 | - | flags |= 1 << 2 | |
| 160 | - | } | |
| 161 | - | if s.HasForegroundRGB { | |
| 162 | - | flags |= 1 << 3 | |
| 163 | - | } | |
| 164 | - | if s.HasBackgroundRGB { | |
| 165 | - | flags |= 1 << 4 | |
| 166 | - | } | |
| 167 | - | if s.HasUnderlineColor { | |
| 168 | - | flags |= 1 << 5 | |
| 169 | - | } | |
| 170 | - | if s.HasUnderlineColorRGB { | |
| 171 | - | flags |= 1 << 6 | |
| 172 | - | } | |
| 173 | - | w.putUint8(flags) | |
| 174 | - | w.putUint16(uint16(s.Attrs)) | |
| 175 | - | putInt16(w, s.Foreground) | |
| 176 | - | putInt16(w, s.Background) | |
| 177 | - | w.putUint8(uint8(s.UnderlineStyle)) | |
| 178 | - | putInt16(w, s.UnderlineColor) | |
| 179 | - | putRGB(w, s.ForegroundRGB) | |
| 180 | - | putRGB(w, s.BackgroundRGB) | |
| 181 | - | putRGB(w, s.UnderlineColorRGB) | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | func getPreviewStyle(r *payloadReader) (renderer.Style, error) { | |
| 185 | - | flags, err := r.getUint8() | |
| 186 | - | if err != nil { | |
| 187 | - | return renderer.Style{}, err | |
| 188 | - | } | |
| 189 | - | if flags&0x80 != 0 { | |
| 190 | - | return renderer.Style{}, ErrRemotePreviewUnsupportedStyle | |
| 191 | - | } | |
| 192 | - | attrs, err := r.getUint16() | |
| 193 | - | if err != nil { | |
| 194 | - | return renderer.Style{}, err | |
| 195 | - | } | |
| 196 | - | fg, err := getInt16(r) | |
| 197 | - | if err != nil { | |
| 198 | - | return renderer.Style{}, err | |
| 199 | - | } | |
| 200 | - | bg, err := getInt16(r) | |
| 201 | - | if err != nil { | |
| 202 | - | return renderer.Style{}, err | |
| 203 | - | } | |
| 204 | - | ul, err := r.getUint8() | |
| 205 | - | if err != nil { | |
| 206 | - | return renderer.Style{}, err | |
| 207 | - | } | |
| 208 | - | ulc, err := getInt16(r) | |
| 209 | - | if err != nil { | |
| 210 | - | return renderer.Style{}, err | |
| 211 | - | } | |
| 212 | - | fgrgb, err := getRGB(r) | |
| 213 | - | if err != nil { | |
| 214 | - | return renderer.Style{}, err | |
| 215 | - | } | |
| 216 | - | bgrgb, err := getRGB(r) | |
| 217 | - | if err != nil { | |
| 218 | - | return renderer.Style{}, err | |
| 219 | - | } | |
| 220 | - | ulrgb, err := getRGB(r) | |
| 221 | - | if err != nil { | |
| 222 | - | return renderer.Style{}, err | |
| 223 | - | } | |
| 224 | - | return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0, | |
| 225 | - | HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0, | |
| 226 | - | HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0, | |
| 227 | - | Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc, | |
| 228 | - | ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte { | |
| 232 | - | if ValidateRemotePreviewRequest(request) != nil { | |
| 233 | - | return nil | |
| 234 | - | } | |
| 235 | - | w := payloadWriter{} | |
| 236 | - | w.putUint16(request.Version) | |
| 237 | - | w.putBytes(request.Target.LifecycleID[:]) | |
| 238 | - | w.putString(request.Target.Endpoint) | |
| 239 | - | w.putString(request.Target.DisplayOrigin) | |
| 240 | - | w.putString(request.Target.SessionName) | |
| 241 | - | w.putString(string(request.Target.LiveTabID)) | |
| 242 | - | w.putUint16(request.Width) | |
| 243 | - | w.putUint16(request.Height) | |
| 244 | - | return w.b | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) { | |
| 248 | - | if len(data) > RemotePreviewMaxBytes { | |
| 249 | - | return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest | |
| 250 | - | } | |
| 251 | - | r := payloadReader{b: data} | |
| 252 | - | var q RemotePreviewRequest | |
| 253 | - | var err error | |
| 254 | - | if q.Version, err = r.getUint16(); err != nil { | |
| 255 | - | return q, err | |
| 256 | - | } | |
| 257 | - | id, err := r.getBytes(16) | |
| 258 | - | if err != nil { | |
| 259 | - | return q, err | |
| 260 | - | } | |
| 261 | - | copy(q.Target.LifecycleID[:], id) | |
| 262 | - | if q.Target.Endpoint, err = r.getString(); err != nil { | |
| 263 | - | return q, err | |
| 264 | - | } | |
| 265 | - | if q.Target.DisplayOrigin, err = r.getString(); err != nil { | |
| 266 | - | return q, err | |
| 267 | - | } | |
| 268 | - | if q.Target.SessionName, err = r.getString(); err != nil { | |
| 269 | - | return q, err | |
| 270 | - | } | |
| 271 | - | tab, err := r.getString() | |
| 272 | - | if err != nil { | |
| 273 | - | return q, err | |
| 274 | - | } | |
| 275 | - | q.Target.LiveTabID = domain.TabStableID(tab) | |
| 276 | - | if q.Width, err = r.getUint16(); err != nil { | |
| 277 | - | return q, err | |
| 278 | - | } | |
| 279 | - | if q.Height, err = r.getUint16(); err != nil { | |
| 280 | - | return q, err | |
| 281 | - | } | |
| 282 | - | if err := r.done(); err != nil { | |
| 283 | - | return q, err | |
| 284 | - | } | |
| 285 | - | if err := ValidateRemotePreviewRequest(q); err != nil { | |
| 286 | - | return q, err | |
| 287 | - | } | |
| 288 | - | return q, nil | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3 | |
| 292 | - | ||
| 293 | - | func MarshalRemotePreview(preview RemotePreview) []byte { | |
| 294 | - | if ValidateRemotePreview(preview) != nil { | |
| 295 | - | return nil | |
| 296 | - | } | |
| 297 | - | w := payloadWriter{} | |
| 298 | - | w.putUint16(preview.Version) | |
| 299 | - | w.putUint8(uint8(preview.Status)) | |
| 300 | - | w.putBytes(preview.LifecycleID[:]) | |
| 301 | - | w.putString(string(preview.TabID)) | |
| 302 | - | w.putUint64(preview.Revision) | |
| 303 | - | w.putUint16(preview.Width) | |
| 304 | - | w.putUint16(preview.Height) | |
| 305 | - | w.putUint32(uint32(len(preview.Cells))) | |
| 306 | - | for _, cell := range preview.Cells { | |
| 307 | - | var flags uint8 | |
| 308 | - | if cell.Continuation { | |
| 309 | - | flags = 1 | |
| 310 | - | } | |
| 311 | - | w.putUint32(uint32(cell.Rune)) | |
| 312 | - | w.putUint8(flags) | |
| 313 | - | putPreviewStyle(&w, cell.Style) | |
| 314 | - | } | |
| 315 | - | return w.b | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |
| 319 | - | if len(data) > RemotePreviewMaxBytes { | |
| 320 | - | return RemotePreview{}, ErrRemotePreviewTooLarge | |
| 321 | - | } | |
| 322 | - | r := payloadReader{b: data} | |
| 323 | - | var p RemotePreview | |
| 324 | - | var err error | |
| 325 | - | if p.Version, err = r.getUint16(); err != nil { | |
| 326 | - | return p, err | |
| 327 | - | } | |
| 328 | - | status, err := r.getUint8() | |
| 329 | - | if err != nil { | |
| 330 | - | return p, err | |
| 331 | - | } | |
| 332 | - | p.Status = RemotePreviewStatus(status) | |
| 333 | - | id, err := r.getBytes(16) | |
| 334 | - | if err != nil { | |
| 335 | - | return p, err | |
| 336 | - | } | |
| 337 | - | copy(p.LifecycleID[:], id) | |
| 338 | - | tab, err := r.getString() | |
| 339 | - | if err != nil { | |
| 340 | - | return p, err | |
| 341 | - | } | |
| 342 | - | p.TabID = domain.TabStableID(tab) | |
| 343 | - | if p.Revision, err = r.getUint64(); err != nil { | |
| 344 | - | return p, err | |
| 345 | - | } | |
| 346 | - | if p.Width, err = r.getUint16(); err != nil { | |
| 347 | - | return p, err | |
| 348 | - | } | |
| 349 | - | if p.Height, err = r.getUint16(); err != nil { | |
| 350 | - | return p, err | |
| 351 | - | } | |
| 352 | - | count, err := r.getUint32() | |
| 353 | - | if err != nil { | |
| 354 | - | return p, err | |
| 355 | - | } | |
| 356 | - | if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |
| 357 | - | return p, ErrRemotePreviewTooLarge | |
| 358 | - | } | |
| 359 | - | if count != 0 { | |
| 360 | - | p.Cells = make([]renderer.Cell, 0, int(count)) | |
| 361 | - | for range int(count) { | |
| 362 | - | runeValue, e := r.getUint32() | |
| 363 | - | if e != nil { | |
| 364 | - | return p, e | |
| 365 | - | } | |
| 366 | - | flags, e := r.getUint8() | |
| 367 | - | if e != nil { | |
| 368 | - | return p, e | |
| 369 | - | } | |
| 370 | - | if flags&^uint8(1) != 0 { | |
| 371 | - | return p, ErrInvalidRemotePreview | |
| 372 | - | } | |
| 373 | - | style, e := getPreviewStyle(&r) | |
| 374 | - | if e != nil { | |
| 375 | - | return p, e | |
| 376 | - | } | |
| 377 | - | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |
| 378 | - | } | |
| 379 | - | } | |
| 380 | - | if err := r.done(); err != nil { | |
| 381 | - | return p, err | |
| 382 | - | } | |
| 383 | - | if err := ValidateRemotePreview(p); err != nil { | |
| 384 | - | return p, err | |
| 385 | - | } | |
| 386 | - | return p, nil | |
| 387 | - | } | |
| 388 | - | ||
| 389 | 92 | func (p RemotePreview) FrameRows() [][]renderer.Cell { | |
| 390 | 93 | if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) { | |
| 391 | 94 | return nil | |
| @@ -3,15 +3,13 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "unicode" | |||
| 6 | "unicode/utf8" | 7 | "unicode/utf8" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | - | // RemotePreviewSchemaVersion is independent from the attachment IPC version. | |||
| 13 | - | const RemotePreviewSchemaVersion uint16 = 1 | |||
| 14 | - | ||||
| 15 | const ( | 13 | const ( | ||
| 16 | RemotePreviewMaxWidth = 256 | 14 | RemotePreviewMaxWidth = 256 | ||
| 17 | RemotePreviewMaxHeight = 128 | 15 | RemotePreviewMaxHeight = 128 | ||
| @@ -19,40 +17,6 @@ const ( | |||||
| 19 | RemotePreviewMaxBytes = 1 << 20 | 17 | RemotePreviewMaxBytes = 1 << 20 | ||
| 20 | ) | 18 | ) | ||
| 21 | 19 | ||||
| 22 | - | // RemotePreviewStatus is a closed response taxonomy. Terminal contents are | |||
| 23 | - | // never carried in an error response. | |||
| 24 | - | type RemotePreviewStatus uint8 | |||
| 25 | - | ||||
| 26 | - | const ( | |||
| 27 | - | RemotePreviewOK RemotePreviewStatus = iota | |||
| 28 | - | RemotePreviewUnavailable | |||
| 29 | - | RemotePreviewNoSuchTarget | |||
| 30 | - | RemotePreviewStale | |||
| 31 | - | RemotePreviewMalformed | |||
| 32 | - | RemotePreviewTooLarge | |||
| 33 | - | ) | |||
| 34 | - | ||||
| 35 | - | // RemotePreviewRequest asks the owning daemon for an in-memory live viewport. | |||
| 36 | - | type RemotePreviewRequest struct { | |||
| 37 | - | Version uint16 | |||
| 38 | - | Target domain.RemoteSessionTarget | |||
| 39 | - | Width uint16 | |||
| 40 | - | Height uint16 | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // RemotePreview is a bounded row-major styled-cell viewport. It is a | |||
| 44 | - | // process-memory DTO and is never persisted, logged, or traced. | |||
| 45 | - | type RemotePreview struct { | |||
| 46 | - | Version uint16 | |||
| 47 | - | Status RemotePreviewStatus | |||
| 48 | - | LifecycleID domain.SessionLifecycleID | |||
| 49 | - | TabID domain.TabStableID | |||
| 50 | - | Revision uint64 | |||
| 51 | - | Width uint16 | |||
| 52 | - | Height uint16 | |||
| 53 | - | Cells []renderer.Cell | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | var ( | 20 | var ( | ||
| 57 | ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request") | 21 | ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request") | ||
| 58 | ErrInvalidRemotePreview = errors.New("ports: invalid remote preview") | 22 | ErrInvalidRemotePreview = errors.New("ports: invalid remote preview") | ||
| @@ -101,7 +65,7 @@ func ValidateRemotePreview(preview RemotePreview) error { | |||||
| 101 | } | 65 | } | ||
| 102 | width := int(preview.Width) | 66 | width := int(preview.Width) | ||
| 103 | for i, cell := range preview.Cells { | 67 | for i, cell := range preview.Cells { | ||
| 104 | - | if !utf8.ValidRune(cell.Rune) || !validRemotePreviewStyle(cell.Style) { | 68 | + | if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validRemotePreviewStyle(cell.Style) { |
| 105 | return ErrInvalidRemotePreview | 69 | return ErrInvalidRemotePreview | ||
| 106 | } | 70 | } | ||
| 107 | rowStart := (i / width) * width | 71 | rowStart := (i / width) * width | ||
| @@ -125,267 +89,6 @@ func validRemotePreviewStyle(style renderer.Style) bool { | |||||
| 125 | style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16 | 89 | style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16 | ||
| 126 | } | 90 | } | ||
| 127 | 91 | ||||
| 128 | - | func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) } | |||
| 129 | - | func getInt16(r *payloadReader) (int, error) { | |||
| 130 | - | n, err := r.getUint16() | |||
| 131 | - | return int(int16(n)), err | |||
| 132 | - | } | |||
| 133 | - | func putRGB(w *payloadWriter, c renderer.RGB) { w.putUint8(c.R); w.putUint8(c.G); w.putUint8(c.B) } | |||
| 134 | - | func getRGB(r *payloadReader) (renderer.RGB, error) { | |||
| 135 | - | red, err := r.getUint8() | |||
| 136 | - | if err != nil { | |||
| 137 | - | return renderer.RGB{}, err | |||
| 138 | - | } | |||
| 139 | - | green, err := r.getUint8() | |||
| 140 | - | if err != nil { | |||
| 141 | - | return renderer.RGB{}, err | |||
| 142 | - | } | |||
| 143 | - | blue, err := r.getUint8() | |||
| 144 | - | if err != nil { | |||
| 145 | - | return renderer.RGB{}, err | |||
| 146 | - | } | |||
| 147 | - | return renderer.RGB{R: red, G: green, B: blue}, nil | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | func putPreviewStyle(w *payloadWriter, s renderer.Style) { | |||
| 151 | - | var flags uint8 | |||
| 152 | - | if s.Bold { | |||
| 153 | - | flags |= 1 << 0 | |||
| 154 | - | } | |||
| 155 | - | if s.Italic { | |||
| 156 | - | flags |= 1 << 1 | |||
| 157 | - | } | |||
| 158 | - | if s.Inverse { | |||
| 159 | - | flags |= 1 << 2 | |||
| 160 | - | } | |||
| 161 | - | if s.HasForegroundRGB { | |||
| 162 | - | flags |= 1 << 3 | |||
| 163 | - | } | |||
| 164 | - | if s.HasBackgroundRGB { | |||
| 165 | - | flags |= 1 << 4 | |||
| 166 | - | } | |||
| 167 | - | if s.HasUnderlineColor { | |||
| 168 | - | flags |= 1 << 5 | |||
| 169 | - | } | |||
| 170 | - | if s.HasUnderlineColorRGB { | |||
| 171 | - | flags |= 1 << 6 | |||
| 172 | - | } | |||
| 173 | - | w.putUint8(flags) | |||
| 174 | - | w.putUint16(uint16(s.Attrs)) | |||
| 175 | - | putInt16(w, s.Foreground) | |||
| 176 | - | putInt16(w, s.Background) | |||
| 177 | - | w.putUint8(uint8(s.UnderlineStyle)) | |||
| 178 | - | putInt16(w, s.UnderlineColor) | |||
| 179 | - | putRGB(w, s.ForegroundRGB) | |||
| 180 | - | putRGB(w, s.BackgroundRGB) | |||
| 181 | - | putRGB(w, s.UnderlineColorRGB) | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | func getPreviewStyle(r *payloadReader) (renderer.Style, error) { | |||
| 185 | - | flags, err := r.getUint8() | |||
| 186 | - | if err != nil { | |||
| 187 | - | return renderer.Style{}, err | |||
| 188 | - | } | |||
| 189 | - | if flags&0x80 != 0 { | |||
| 190 | - | return renderer.Style{}, ErrRemotePreviewUnsupportedStyle | |||
| 191 | - | } | |||
| 192 | - | attrs, err := r.getUint16() | |||
| 193 | - | if err != nil { | |||
| 194 | - | return renderer.Style{}, err | |||
| 195 | - | } | |||
| 196 | - | fg, err := getInt16(r) | |||
| 197 | - | if err != nil { | |||
| 198 | - | return renderer.Style{}, err | |||
| 199 | - | } | |||
| 200 | - | bg, err := getInt16(r) | |||
| 201 | - | if err != nil { | |||
| 202 | - | return renderer.Style{}, err | |||
| 203 | - | } | |||
| 204 | - | ul, err := r.getUint8() | |||
| 205 | - | if err != nil { | |||
| 206 | - | return renderer.Style{}, err | |||
| 207 | - | } | |||
| 208 | - | ulc, err := getInt16(r) | |||
| 209 | - | if err != nil { | |||
| 210 | - | return renderer.Style{}, err | |||
| 211 | - | } | |||
| 212 | - | fgrgb, err := getRGB(r) | |||
| 213 | - | if err != nil { | |||
| 214 | - | return renderer.Style{}, err | |||
| 215 | - | } | |||
| 216 | - | bgrgb, err := getRGB(r) | |||
| 217 | - | if err != nil { | |||
| 218 | - | return renderer.Style{}, err | |||
| 219 | - | } | |||
| 220 | - | ulrgb, err := getRGB(r) | |||
| 221 | - | if err != nil { | |||
| 222 | - | return renderer.Style{}, err | |||
| 223 | - | } | |||
| 224 | - | return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0, | |||
| 225 | - | HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0, | |||
| 226 | - | HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0, | |||
| 227 | - | Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc, | |||
| 228 | - | ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte { | |||
| 232 | - | if ValidateRemotePreviewRequest(request) != nil { | |||
| 233 | - | return nil | |||
| 234 | - | } | |||
| 235 | - | w := payloadWriter{} | |||
| 236 | - | w.putUint16(request.Version) | |||
| 237 | - | w.putBytes(request.Target.LifecycleID[:]) | |||
| 238 | - | w.putString(request.Target.Endpoint) | |||
| 239 | - | w.putString(request.Target.DisplayOrigin) | |||
| 240 | - | w.putString(request.Target.SessionName) | |||
| 241 | - | w.putString(string(request.Target.LiveTabID)) | |||
| 242 | - | w.putUint16(request.Width) | |||
| 243 | - | w.putUint16(request.Height) | |||
| 244 | - | return w.b | |||
| 245 | - | } | |||
| 246 | - | ||||
| 247 | - | func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) { | |||
| 248 | - | if len(data) > RemotePreviewMaxBytes { | |||
| 249 | - | return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest | |||
| 250 | - | } | |||
| 251 | - | r := payloadReader{b: data} | |||
| 252 | - | var q RemotePreviewRequest | |||
| 253 | - | var err error | |||
| 254 | - | if q.Version, err = r.getUint16(); err != nil { | |||
| 255 | - | return q, err | |||
| 256 | - | } | |||
| 257 | - | id, err := r.getBytes(16) | |||
| 258 | - | if err != nil { | |||
| 259 | - | return q, err | |||
| 260 | - | } | |||
| 261 | - | copy(q.Target.LifecycleID[:], id) | |||
| 262 | - | if q.Target.Endpoint, err = r.getString(); err != nil { | |||
| 263 | - | return q, err | |||
| 264 | - | } | |||
| 265 | - | if q.Target.DisplayOrigin, err = r.getString(); err != nil { | |||
| 266 | - | return q, err | |||
| 267 | - | } | |||
| 268 | - | if q.Target.SessionName, err = r.getString(); err != nil { | |||
| 269 | - | return q, err | |||
| 270 | - | } | |||
| 271 | - | tab, err := r.getString() | |||
| 272 | - | if err != nil { | |||
| 273 | - | return q, err | |||
| 274 | - | } | |||
| 275 | - | q.Target.LiveTabID = domain.TabStableID(tab) | |||
| 276 | - | if q.Width, err = r.getUint16(); err != nil { | |||
| 277 | - | return q, err | |||
| 278 | - | } | |||
| 279 | - | if q.Height, err = r.getUint16(); err != nil { | |||
| 280 | - | return q, err | |||
| 281 | - | } | |||
| 282 | - | if err := r.done(); err != nil { | |||
| 283 | - | return q, err | |||
| 284 | - | } | |||
| 285 | - | if err := ValidateRemotePreviewRequest(q); err != nil { | |||
| 286 | - | return q, err | |||
| 287 | - | } | |||
| 288 | - | return q, nil | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3 | |||
| 292 | - | ||||
| 293 | - | func MarshalRemotePreview(preview RemotePreview) []byte { | |||
| 294 | - | if ValidateRemotePreview(preview) != nil { | |||
| 295 | - | return nil | |||
| 296 | - | } | |||
| 297 | - | w := payloadWriter{} | |||
| 298 | - | w.putUint16(preview.Version) | |||
| 299 | - | w.putUint8(uint8(preview.Status)) | |||
| 300 | - | w.putBytes(preview.LifecycleID[:]) | |||
| 301 | - | w.putString(string(preview.TabID)) | |||
| 302 | - | w.putUint64(preview.Revision) | |||
| 303 | - | w.putUint16(preview.Width) | |||
| 304 | - | w.putUint16(preview.Height) | |||
| 305 | - | w.putUint32(uint32(len(preview.Cells))) | |||
| 306 | - | for _, cell := range preview.Cells { | |||
| 307 | - | var flags uint8 | |||
| 308 | - | if cell.Continuation { | |||
| 309 | - | flags = 1 | |||
| 310 | - | } | |||
| 311 | - | w.putUint32(uint32(cell.Rune)) | |||
| 312 | - | w.putUint8(flags) | |||
| 313 | - | putPreviewStyle(&w, cell.Style) | |||
| 314 | - | } | |||
| 315 | - | return w.b | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |||
| 319 | - | if len(data) > RemotePreviewMaxBytes { | |||
| 320 | - | return RemotePreview{}, ErrRemotePreviewTooLarge | |||
| 321 | - | } | |||
| 322 | - | r := payloadReader{b: data} | |||
| 323 | - | var p RemotePreview | |||
| 324 | - | var err error | |||
| 325 | - | if p.Version, err = r.getUint16(); err != nil { | |||
| 326 | - | return p, err | |||
| 327 | - | } | |||
| 328 | - | status, err := r.getUint8() | |||
| 329 | - | if err != nil { | |||
| 330 | - | return p, err | |||
| 331 | - | } | |||
| 332 | - | p.Status = RemotePreviewStatus(status) | |||
| 333 | - | id, err := r.getBytes(16) | |||
| 334 | - | if err != nil { | |||
| 335 | - | return p, err | |||
| 336 | - | } | |||
| 337 | - | copy(p.LifecycleID[:], id) | |||
| 338 | - | tab, err := r.getString() | |||
| 339 | - | if err != nil { | |||
| 340 | - | return p, err | |||
| 341 | - | } | |||
| 342 | - | p.TabID = domain.TabStableID(tab) | |||
| 343 | - | if p.Revision, err = r.getUint64(); err != nil { | |||
| 344 | - | return p, err | |||
| 345 | - | } | |||
| 346 | - | if p.Width, err = r.getUint16(); err != nil { | |||
| 347 | - | return p, err | |||
| 348 | - | } | |||
| 349 | - | if p.Height, err = r.getUint16(); err != nil { | |||
| 350 | - | return p, err | |||
| 351 | - | } | |||
| 352 | - | count, err := r.getUint32() | |||
| 353 | - | if err != nil { | |||
| 354 | - | return p, err | |||
| 355 | - | } | |||
| 356 | - | if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |||
| 357 | - | return p, ErrRemotePreviewTooLarge | |||
| 358 | - | } | |||
| 359 | - | if count != 0 { | |||
| 360 | - | p.Cells = make([]renderer.Cell, 0, int(count)) | |||
| 361 | - | for range int(count) { | |||
| 362 | - | runeValue, e := r.getUint32() | |||
| 363 | - | if e != nil { | |||
| 364 | - | return p, e | |||
| 365 | - | } | |||
| 366 | - | flags, e := r.getUint8() | |||
| 367 | - | if e != nil { | |||
| 368 | - | return p, e | |||
| 369 | - | } | |||
| 370 | - | if flags&^uint8(1) != 0 { | |||
| 371 | - | return p, ErrInvalidRemotePreview | |||
| 372 | - | } | |||
| 373 | - | style, e := getPreviewStyle(&r) | |||
| 374 | - | if e != nil { | |||
| 375 | - | return p, e | |||
| 376 | - | } | |||
| 377 | - | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |||
| 378 | - | } | |||
| 379 | - | } | |||
| 380 | - | if err := r.done(); err != nil { | |||
| 381 | - | return p, err | |||
| 382 | - | } | |||
| 383 | - | if err := ValidateRemotePreview(p); err != nil { | |||
| 384 | - | return p, err | |||
| 385 | - | } | |||
| 386 | - | return p, nil | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | func (p RemotePreview) FrameRows() [][]renderer.Cell { | 92 | func (p RemotePreview) FrameRows() [][]renderer.Cell { | ||
| 390 | if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) { | 93 | if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) { | ||
| 391 | return nil | 94 | return nil | ||
M internal/ports/remote_preview_hardening_test.go +18 -0
| @@ -116,6 +116,24 @@ func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) { | |||
| 116 | 116 | } | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | func TestRemotePreviewRejectsControlRunes(t *testing.T) { | |
| 120 | + | valid := RemotePreview{ | |
| 121 | + | Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, | |
| 122 | + | LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, | |
| 123 | + | Cells: []renderer.Cell{{Rune: 'x'}}, | |
| 124 | + | } | |
| 125 | + | for _, control := range []rune{'\x01', '\x1b', '\x7f', '\u0085'} { | |
| 126 | + | preview := valid | |
| 127 | + | preview.Cells = []renderer.Cell{{Rune: control}} | |
| 128 | + | if err := ValidateRemotePreview(preview); err != ErrInvalidRemotePreview { | |
| 129 | + | t.Fatalf("control rune %U: ValidateRemotePreview() error = %v, want %v", control, err, ErrInvalidRemotePreview) | |
| 130 | + | } | |
| 131 | + | if MarshalRemotePreview(preview) != nil { | |
| 132 | + | t.Fatalf("control rune %U was marshaled", control) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 119 | 137 | func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) { | |
| 120 | 138 | target := previewTargetForTest() | |
| 121 | 139 | target.Endpoint = strings.Repeat("h", math.MaxUint16+1) | |
| @@ -116,6 +116,24 @@ func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) { | |||||
| 116 | } | 116 | } | ||
| 117 | } | 117 | } | ||
| 118 | 118 | ||||
| 119 | + | func TestRemotePreviewRejectsControlRunes(t *testing.T) { | |||
| 120 | + | valid := RemotePreview{ | |||
| 121 | + | Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, | |||
| 122 | + | LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, | |||
| 123 | + | Cells: []renderer.Cell{{Rune: 'x'}}, | |||
| 124 | + | } | |||
| 125 | + | for _, control := range []rune{'\x01', '\x1b', '\x7f', '\u0085'} { | |||
| 126 | + | preview := valid | |||
| 127 | + | preview.Cells = []renderer.Cell{{Rune: control}} | |||
| 128 | + | if err := ValidateRemotePreview(preview); err != ErrInvalidRemotePreview { | |||
| 129 | + | t.Fatalf("control rune %U: ValidateRemotePreview() error = %v, want %v", control, err, ErrInvalidRemotePreview) | |||
| 130 | + | } | |||
| 131 | + | if MarshalRemotePreview(preview) != nil { | |||
| 132 | + | t.Fatalf("control rune %U was marshaled", control) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 119 | func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) { | 137 | func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) { | ||
| 120 | target := previewTargetForTest() | 138 | target := previewTargetForTest() | ||
| 121 | target.Endpoint = strings.Repeat("h", math.MaxUint16+1) | 139 | target.Endpoint = strings.Repeat("h", math.MaxUint16+1) | ||
M internal/ports/wire.go +268 -0
| @@ -1675,3 +1675,271 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 1675 | 1675 | } | |
| 1676 | 1676 | return Sessions{Sessions: sessions}, nil | |
| 1677 | 1677 | } | |
| 1678 | + | ||
| 1679 | + | func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) } | |
| 1680 | + | ||
| 1681 | + | func getInt16(r *payloadReader) (int, error) { | |
| 1682 | + | n, err := r.getUint16() | |
| 1683 | + | return int(int16(n)), err | |
| 1684 | + | } | |
| 1685 | + | ||
| 1686 | + | func putRGB(w *payloadWriter, c renderer.RGB) { | |
| 1687 | + | w.putUint8(c.R) | |
| 1688 | + | w.putUint8(c.G) | |
| 1689 | + | w.putUint8(c.B) | |
| 1690 | + | } | |
| 1691 | + | ||
| 1692 | + | func getRGB(r *payloadReader) (renderer.RGB, error) { | |
| 1693 | + | red, err := r.getUint8() | |
| 1694 | + | if err != nil { | |
| 1695 | + | return renderer.RGB{}, err | |
| 1696 | + | } | |
| 1697 | + | green, err := r.getUint8() | |
| 1698 | + | if err != nil { | |
| 1699 | + | return renderer.RGB{}, err | |
| 1700 | + | } | |
| 1701 | + | blue, err := r.getUint8() | |
| 1702 | + | if err != nil { | |
| 1703 | + | return renderer.RGB{}, err | |
| 1704 | + | } | |
| 1705 | + | return renderer.RGB{R: red, G: green, B: blue}, nil | |
| 1706 | + | } | |
| 1707 | + | ||
| 1708 | + | func putPreviewStyle(w *payloadWriter, s renderer.Style) { | |
| 1709 | + | var flags uint8 | |
| 1710 | + | if s.Bold { | |
| 1711 | + | flags |= 1 << 0 | |
| 1712 | + | } | |
| 1713 | + | if s.Italic { | |
| 1714 | + | flags |= 1 << 1 | |
| 1715 | + | } | |
| 1716 | + | if s.Inverse { | |
| 1717 | + | flags |= 1 << 2 | |
| 1718 | + | } | |
| 1719 | + | if s.HasForegroundRGB { | |
| 1720 | + | flags |= 1 << 3 | |
| 1721 | + | } | |
| 1722 | + | if s.HasBackgroundRGB { | |
| 1723 | + | flags |= 1 << 4 | |
| 1724 | + | } | |
| 1725 | + | if s.HasUnderlineColor { | |
| 1726 | + | flags |= 1 << 5 | |
| 1727 | + | } | |
| 1728 | + | if s.HasUnderlineColorRGB { | |
| 1729 | + | flags |= 1 << 6 | |
| 1730 | + | } | |
| 1731 | + | w.putUint8(flags) | |
| 1732 | + | w.putUint16(uint16(s.Attrs)) | |
| 1733 | + | putInt16(w, s.Foreground) | |
| 1734 | + | putInt16(w, s.Background) | |
| 1735 | + | w.putUint8(uint8(s.UnderlineStyle)) | |
| 1736 | + | putInt16(w, s.UnderlineColor) | |
| 1737 | + | putRGB(w, s.ForegroundRGB) | |
| 1738 | + | putRGB(w, s.BackgroundRGB) | |
| 1739 | + | putRGB(w, s.UnderlineColorRGB) | |
| 1740 | + | } | |
| 1741 | + | ||
| 1742 | + | func getPreviewStyle(r *payloadReader) (renderer.Style, error) { | |
| 1743 | + | flags, err := r.getUint8() | |
| 1744 | + | if err != nil { | |
| 1745 | + | return renderer.Style{}, err | |
| 1746 | + | } | |
| 1747 | + | if flags&0x80 != 0 { | |
| 1748 | + | return renderer.Style{}, ErrRemotePreviewUnsupportedStyle | |
| 1749 | + | } | |
| 1750 | + | attrs, err := r.getUint16() | |
| 1751 | + | if err != nil { | |
| 1752 | + | return renderer.Style{}, err | |
| 1753 | + | } | |
| 1754 | + | fg, err := getInt16(r) | |
| 1755 | + | if err != nil { | |
| 1756 | + | return renderer.Style{}, err | |
| 1757 | + | } | |
| 1758 | + | bg, err := getInt16(r) | |
| 1759 | + | if err != nil { | |
| 1760 | + | return renderer.Style{}, err | |
| 1761 | + | } | |
| 1762 | + | ul, err := r.getUint8() | |
| 1763 | + | if err != nil { | |
| 1764 | + | return renderer.Style{}, err | |
| 1765 | + | } | |
| 1766 | + | ulc, err := getInt16(r) | |
| 1767 | + | if err != nil { | |
| 1768 | + | return renderer.Style{}, err | |
| 1769 | + | } | |
| 1770 | + | fgrgb, err := getRGB(r) | |
| 1771 | + | if err != nil { | |
| 1772 | + | return renderer.Style{}, err | |
| 1773 | + | } | |
| 1774 | + | bgrgb, err := getRGB(r) | |
| 1775 | + | if err != nil { | |
| 1776 | + | return renderer.Style{}, err | |
| 1777 | + | } | |
| 1778 | + | ulrgb, err := getRGB(r) | |
| 1779 | + | if err != nil { | |
| 1780 | + | return renderer.Style{}, err | |
| 1781 | + | } | |
| 1782 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0, | |
| 1783 | + | HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0, | |
| 1784 | + | HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0, | |
| 1785 | + | Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc, | |
| 1786 | + | ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil | |
| 1787 | + | } | |
| 1788 | + | ||
| 1789 | + | func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte { | |
| 1790 | + | if ValidateRemotePreviewRequest(request) != nil { | |
| 1791 | + | return nil | |
| 1792 | + | } | |
| 1793 | + | w := payloadWriter{} | |
| 1794 | + | w.putUint16(request.Version) | |
| 1795 | + | w.putBytes(request.Target.LifecycleID[:]) | |
| 1796 | + | w.putString(request.Target.Endpoint) | |
| 1797 | + | w.putString(request.Target.DisplayOrigin) | |
| 1798 | + | w.putString(request.Target.SessionName) | |
| 1799 | + | w.putString(string(request.Target.LiveTabID)) | |
| 1800 | + | w.putUint16(request.Width) | |
| 1801 | + | w.putUint16(request.Height) | |
| 1802 | + | return w.b | |
| 1803 | + | } | |
| 1804 | + | ||
| 1805 | + | func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) { | |
| 1806 | + | if len(data) > RemotePreviewMaxBytes { | |
| 1807 | + | return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest | |
| 1808 | + | } | |
| 1809 | + | r := payloadReader{b: data} | |
| 1810 | + | var q RemotePreviewRequest | |
| 1811 | + | var err error | |
| 1812 | + | if q.Version, err = r.getUint16(); err != nil { | |
| 1813 | + | return q, err | |
| 1814 | + | } | |
| 1815 | + | id, err := r.getBytes(16) | |
| 1816 | + | if err != nil { | |
| 1817 | + | return q, err | |
| 1818 | + | } | |
| 1819 | + | copy(q.Target.LifecycleID[:], id) | |
| 1820 | + | if q.Target.Endpoint, err = r.getString(); err != nil { | |
| 1821 | + | return q, err | |
| 1822 | + | } | |
| 1823 | + | if q.Target.DisplayOrigin, err = r.getString(); err != nil { | |
| 1824 | + | return q, err | |
| 1825 | + | } | |
| 1826 | + | if q.Target.SessionName, err = r.getString(); err != nil { | |
| 1827 | + | return q, err | |
| 1828 | + | } | |
| 1829 | + | tab, err := r.getString() | |
| 1830 | + | if err != nil { | |
| 1831 | + | return q, err | |
| 1832 | + | } | |
| 1833 | + | q.Target.LiveTabID = domain.TabStableID(tab) | |
| 1834 | + | if q.Width, err = r.getUint16(); err != nil { | |
| 1835 | + | return q, err | |
| 1836 | + | } | |
| 1837 | + | if q.Height, err = r.getUint16(); err != nil { | |
| 1838 | + | return q, err | |
| 1839 | + | } | |
| 1840 | + | if err := r.done(); err != nil { | |
| 1841 | + | return q, err | |
| 1842 | + | } | |
| 1843 | + | if err := ValidateRemotePreviewRequest(q); err != nil { | |
| 1844 | + | return q, err | |
| 1845 | + | } | |
| 1846 | + | return q, nil | |
| 1847 | + | } | |
| 1848 | + | ||
| 1849 | + | const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3 | |
| 1850 | + | ||
| 1851 | + | func MarshalRemotePreview(preview RemotePreview) []byte { | |
| 1852 | + | if ValidateRemotePreview(preview) != nil { | |
| 1853 | + | return nil | |
| 1854 | + | } | |
| 1855 | + | w := payloadWriter{} | |
| 1856 | + | w.putUint16(preview.Version) | |
| 1857 | + | w.putUint8(uint8(preview.Status)) | |
| 1858 | + | w.putBytes(preview.LifecycleID[:]) | |
| 1859 | + | w.putString(string(preview.TabID)) | |
| 1860 | + | w.putUint64(preview.Revision) | |
| 1861 | + | w.putUint16(preview.Width) | |
| 1862 | + | w.putUint16(preview.Height) | |
| 1863 | + | w.putUint32(uint32(len(preview.Cells))) | |
| 1864 | + | for _, cell := range preview.Cells { | |
| 1865 | + | var flags uint8 | |
| 1866 | + | if cell.Continuation { | |
| 1867 | + | flags = 1 | |
| 1868 | + | } | |
| 1869 | + | w.putUint32(uint32(cell.Rune)) | |
| 1870 | + | w.putUint8(flags) | |
| 1871 | + | putPreviewStyle(&w, cell.Style) | |
| 1872 | + | } | |
| 1873 | + | return w.b | |
| 1874 | + | } | |
| 1875 | + | ||
| 1876 | + | func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |
| 1877 | + | if len(data) > RemotePreviewMaxBytes { | |
| 1878 | + | return RemotePreview{}, ErrRemotePreviewTooLarge | |
| 1879 | + | } | |
| 1880 | + | r := payloadReader{b: data} | |
| 1881 | + | var p RemotePreview | |
| 1882 | + | var err error | |
| 1883 | + | if p.Version, err = r.getUint16(); err != nil { | |
| 1884 | + | return p, err | |
| 1885 | + | } | |
| 1886 | + | status, err := r.getUint8() | |
| 1887 | + | if err != nil { | |
| 1888 | + | return p, err | |
| 1889 | + | } | |
| 1890 | + | p.Status = RemotePreviewStatus(status) | |
| 1891 | + | id, err := r.getBytes(16) | |
| 1892 | + | if err != nil { | |
| 1893 | + | return p, err | |
| 1894 | + | } | |
| 1895 | + | copy(p.LifecycleID[:], id) | |
| 1896 | + | tab, err := r.getString() | |
| 1897 | + | if err != nil { | |
| 1898 | + | return p, err | |
| 1899 | + | } | |
| 1900 | + | p.TabID = domain.TabStableID(tab) | |
| 1901 | + | if p.Revision, err = r.getUint64(); err != nil { | |
| 1902 | + | return p, err | |
| 1903 | + | } | |
| 1904 | + | if p.Width, err = r.getUint16(); err != nil { | |
| 1905 | + | return p, err | |
| 1906 | + | } | |
| 1907 | + | if p.Height, err = r.getUint16(); err != nil { | |
| 1908 | + | return p, err | |
| 1909 | + | } | |
| 1910 | + | count, err := r.getUint32() | |
| 1911 | + | if err != nil { | |
| 1912 | + | return p, err | |
| 1913 | + | } | |
| 1914 | + | if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |
| 1915 | + | return p, ErrRemotePreviewTooLarge | |
| 1916 | + | } | |
| 1917 | + | if count != 0 { | |
| 1918 | + | p.Cells = make([]renderer.Cell, 0, int(count)) | |
| 1919 | + | for range int(count) { | |
| 1920 | + | runeValue, e := r.getUint32() | |
| 1921 | + | if e != nil { | |
| 1922 | + | return p, e | |
| 1923 | + | } | |
| 1924 | + | flags, e := r.getUint8() | |
| 1925 | + | if e != nil { | |
| 1926 | + | return p, e | |
| 1927 | + | } | |
| 1928 | + | if flags&^uint8(1) != 0 { | |
| 1929 | + | return p, ErrInvalidRemotePreview | |
| 1930 | + | } | |
| 1931 | + | style, e := getPreviewStyle(&r) | |
| 1932 | + | if e != nil { | |
| 1933 | + | return p, e | |
| 1934 | + | } | |
| 1935 | + | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |
| 1936 | + | } | |
| 1937 | + | } | |
| 1938 | + | if err := r.done(); err != nil { | |
| 1939 | + | return p, err | |
| 1940 | + | } | |
| 1941 | + | if err := ValidateRemotePreview(p); err != nil { | |
| 1942 | + | return p, err | |
| 1943 | + | } | |
| 1944 | + | return p, nil | |
| 1945 | + | } | |
| @@ -1675,3 +1675,271 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||||
| 1675 | } | 1675 | } | ||
| 1676 | return Sessions{Sessions: sessions}, nil | 1676 | return Sessions{Sessions: sessions}, nil | ||
| 1677 | } | 1677 | } | ||
| 1678 | + | ||||
| 1679 | + | func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) } | |||
| 1680 | + | ||||
| 1681 | + | func getInt16(r *payloadReader) (int, error) { | |||
| 1682 | + | n, err := r.getUint16() | |||
| 1683 | + | return int(int16(n)), err | |||
| 1684 | + | } | |||
| 1685 | + | ||||
| 1686 | + | func putRGB(w *payloadWriter, c renderer.RGB) { | |||
| 1687 | + | w.putUint8(c.R) | |||
| 1688 | + | w.putUint8(c.G) | |||
| 1689 | + | w.putUint8(c.B) | |||
| 1690 | + | } | |||
| 1691 | + | ||||
| 1692 | + | func getRGB(r *payloadReader) (renderer.RGB, error) { | |||
| 1693 | + | red, err := r.getUint8() | |||
| 1694 | + | if err != nil { | |||
| 1695 | + | return renderer.RGB{}, err | |||
| 1696 | + | } | |||
| 1697 | + | green, err := r.getUint8() | |||
| 1698 | + | if err != nil { | |||
| 1699 | + | return renderer.RGB{}, err | |||
| 1700 | + | } | |||
| 1701 | + | blue, err := r.getUint8() | |||
| 1702 | + | if err != nil { | |||
| 1703 | + | return renderer.RGB{}, err | |||
| 1704 | + | } | |||
| 1705 | + | return renderer.RGB{R: red, G: green, B: blue}, nil | |||
| 1706 | + | } | |||
| 1707 | + | ||||
| 1708 | + | func putPreviewStyle(w *payloadWriter, s renderer.Style) { | |||
| 1709 | + | var flags uint8 | |||
| 1710 | + | if s.Bold { | |||
| 1711 | + | flags |= 1 << 0 | |||
| 1712 | + | } | |||
| 1713 | + | if s.Italic { | |||
| 1714 | + | flags |= 1 << 1 | |||
| 1715 | + | } | |||
| 1716 | + | if s.Inverse { | |||
| 1717 | + | flags |= 1 << 2 | |||
| 1718 | + | } | |||
| 1719 | + | if s.HasForegroundRGB { | |||
| 1720 | + | flags |= 1 << 3 | |||
| 1721 | + | } | |||
| 1722 | + | if s.HasBackgroundRGB { | |||
| 1723 | + | flags |= 1 << 4 | |||
| 1724 | + | } | |||
| 1725 | + | if s.HasUnderlineColor { | |||
| 1726 | + | flags |= 1 << 5 | |||
| 1727 | + | } | |||
| 1728 | + | if s.HasUnderlineColorRGB { | |||
| 1729 | + | flags |= 1 << 6 | |||
| 1730 | + | } | |||
| 1731 | + | w.putUint8(flags) | |||
| 1732 | + | w.putUint16(uint16(s.Attrs)) | |||
| 1733 | + | putInt16(w, s.Foreground) | |||
| 1734 | + | putInt16(w, s.Background) | |||
| 1735 | + | w.putUint8(uint8(s.UnderlineStyle)) | |||
| 1736 | + | putInt16(w, s.UnderlineColor) | |||
| 1737 | + | putRGB(w, s.ForegroundRGB) | |||
| 1738 | + | putRGB(w, s.BackgroundRGB) | |||
| 1739 | + | putRGB(w, s.UnderlineColorRGB) | |||
| 1740 | + | } | |||
| 1741 | + | ||||
| 1742 | + | func getPreviewStyle(r *payloadReader) (renderer.Style, error) { | |||
| 1743 | + | flags, err := r.getUint8() | |||
| 1744 | + | if err != nil { | |||
| 1745 | + | return renderer.Style{}, err | |||
| 1746 | + | } | |||
| 1747 | + | if flags&0x80 != 0 { | |||
| 1748 | + | return renderer.Style{}, ErrRemotePreviewUnsupportedStyle | |||
| 1749 | + | } | |||
| 1750 | + | attrs, err := r.getUint16() | |||
| 1751 | + | if err != nil { | |||
| 1752 | + | return renderer.Style{}, err | |||
| 1753 | + | } | |||
| 1754 | + | fg, err := getInt16(r) | |||
| 1755 | + | if err != nil { | |||
| 1756 | + | return renderer.Style{}, err | |||
| 1757 | + | } | |||
| 1758 | + | bg, err := getInt16(r) | |||
| 1759 | + | if err != nil { | |||
| 1760 | + | return renderer.Style{}, err | |||
| 1761 | + | } | |||
| 1762 | + | ul, err := r.getUint8() | |||
| 1763 | + | if err != nil { | |||
| 1764 | + | return renderer.Style{}, err | |||
| 1765 | + | } | |||
| 1766 | + | ulc, err := getInt16(r) | |||
| 1767 | + | if err != nil { | |||
| 1768 | + | return renderer.Style{}, err | |||
| 1769 | + | } | |||
| 1770 | + | fgrgb, err := getRGB(r) | |||
| 1771 | + | if err != nil { | |||
| 1772 | + | return renderer.Style{}, err | |||
| 1773 | + | } | |||
| 1774 | + | bgrgb, err := getRGB(r) | |||
| 1775 | + | if err != nil { | |||
| 1776 | + | return renderer.Style{}, err | |||
| 1777 | + | } | |||
| 1778 | + | ulrgb, err := getRGB(r) | |||
| 1779 | + | if err != nil { | |||
| 1780 | + | return renderer.Style{}, err | |||
| 1781 | + | } | |||
| 1782 | + | return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0, | |||
| 1783 | + | HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0, | |||
| 1784 | + | HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0, | |||
| 1785 | + | Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc, | |||
| 1786 | + | ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil | |||
| 1787 | + | } | |||
| 1788 | + | ||||
| 1789 | + | func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte { | |||
| 1790 | + | if ValidateRemotePreviewRequest(request) != nil { | |||
| 1791 | + | return nil | |||
| 1792 | + | } | |||
| 1793 | + | w := payloadWriter{} | |||
| 1794 | + | w.putUint16(request.Version) | |||
| 1795 | + | w.putBytes(request.Target.LifecycleID[:]) | |||
| 1796 | + | w.putString(request.Target.Endpoint) | |||
| 1797 | + | w.putString(request.Target.DisplayOrigin) | |||
| 1798 | + | w.putString(request.Target.SessionName) | |||
| 1799 | + | w.putString(string(request.Target.LiveTabID)) | |||
| 1800 | + | w.putUint16(request.Width) | |||
| 1801 | + | w.putUint16(request.Height) | |||
| 1802 | + | return w.b | |||
| 1803 | + | } | |||
| 1804 | + | ||||
| 1805 | + | func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) { | |||
| 1806 | + | if len(data) > RemotePreviewMaxBytes { | |||
| 1807 | + | return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest | |||
| 1808 | + | } | |||
| 1809 | + | r := payloadReader{b: data} | |||
| 1810 | + | var q RemotePreviewRequest | |||
| 1811 | + | var err error | |||
| 1812 | + | if q.Version, err = r.getUint16(); err != nil { | |||
| 1813 | + | return q, err | |||
| 1814 | + | } | |||
| 1815 | + | id, err := r.getBytes(16) | |||
| 1816 | + | if err != nil { | |||
| 1817 | + | return q, err | |||
| 1818 | + | } | |||
| 1819 | + | copy(q.Target.LifecycleID[:], id) | |||
| 1820 | + | if q.Target.Endpoint, err = r.getString(); err != nil { | |||
| 1821 | + | return q, err | |||
| 1822 | + | } | |||
| 1823 | + | if q.Target.DisplayOrigin, err = r.getString(); err != nil { | |||
| 1824 | + | return q, err | |||
| 1825 | + | } | |||
| 1826 | + | if q.Target.SessionName, err = r.getString(); err != nil { | |||
| 1827 | + | return q, err | |||
| 1828 | + | } | |||
| 1829 | + | tab, err := r.getString() | |||
| 1830 | + | if err != nil { | |||
| 1831 | + | return q, err | |||
| 1832 | + | } | |||
| 1833 | + | q.Target.LiveTabID = domain.TabStableID(tab) | |||
| 1834 | + | if q.Width, err = r.getUint16(); err != nil { | |||
| 1835 | + | return q, err | |||
| 1836 | + | } | |||
| 1837 | + | if q.Height, err = r.getUint16(); err != nil { | |||
| 1838 | + | return q, err | |||
| 1839 | + | } | |||
| 1840 | + | if err := r.done(); err != nil { | |||
| 1841 | + | return q, err | |||
| 1842 | + | } | |||
| 1843 | + | if err := ValidateRemotePreviewRequest(q); err != nil { | |||
| 1844 | + | return q, err | |||
| 1845 | + | } | |||
| 1846 | + | return q, nil | |||
| 1847 | + | } | |||
| 1848 | + | ||||
| 1849 | + | const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3 | |||
| 1850 | + | ||||
| 1851 | + | func MarshalRemotePreview(preview RemotePreview) []byte { | |||
| 1852 | + | if ValidateRemotePreview(preview) != nil { | |||
| 1853 | + | return nil | |||
| 1854 | + | } | |||
| 1855 | + | w := payloadWriter{} | |||
| 1856 | + | w.putUint16(preview.Version) | |||
| 1857 | + | w.putUint8(uint8(preview.Status)) | |||
| 1858 | + | w.putBytes(preview.LifecycleID[:]) | |||
| 1859 | + | w.putString(string(preview.TabID)) | |||
| 1860 | + | w.putUint64(preview.Revision) | |||
| 1861 | + | w.putUint16(preview.Width) | |||
| 1862 | + | w.putUint16(preview.Height) | |||
| 1863 | + | w.putUint32(uint32(len(preview.Cells))) | |||
| 1864 | + | for _, cell := range preview.Cells { | |||
| 1865 | + | var flags uint8 | |||
| 1866 | + | if cell.Continuation { | |||
| 1867 | + | flags = 1 | |||
| 1868 | + | } | |||
| 1869 | + | w.putUint32(uint32(cell.Rune)) | |||
| 1870 | + | w.putUint8(flags) | |||
| 1871 | + | putPreviewStyle(&w, cell.Style) | |||
| 1872 | + | } | |||
| 1873 | + | return w.b | |||
| 1874 | + | } | |||
| 1875 | + | ||||
| 1876 | + | func UnmarshalRemotePreview(data []byte) (RemotePreview, error) { | |||
| 1877 | + | if len(data) > RemotePreviewMaxBytes { | |||
| 1878 | + | return RemotePreview{}, ErrRemotePreviewTooLarge | |||
| 1879 | + | } | |||
| 1880 | + | r := payloadReader{b: data} | |||
| 1881 | + | var p RemotePreview | |||
| 1882 | + | var err error | |||
| 1883 | + | if p.Version, err = r.getUint16(); err != nil { | |||
| 1884 | + | return p, err | |||
| 1885 | + | } | |||
| 1886 | + | status, err := r.getUint8() | |||
| 1887 | + | if err != nil { | |||
| 1888 | + | return p, err | |||
| 1889 | + | } | |||
| 1890 | + | p.Status = RemotePreviewStatus(status) | |||
| 1891 | + | id, err := r.getBytes(16) | |||
| 1892 | + | if err != nil { | |||
| 1893 | + | return p, err | |||
| 1894 | + | } | |||
| 1895 | + | copy(p.LifecycleID[:], id) | |||
| 1896 | + | tab, err := r.getString() | |||
| 1897 | + | if err != nil { | |||
| 1898 | + | return p, err | |||
| 1899 | + | } | |||
| 1900 | + | p.TabID = domain.TabStableID(tab) | |||
| 1901 | + | if p.Revision, err = r.getUint64(); err != nil { | |||
| 1902 | + | return p, err | |||
| 1903 | + | } | |||
| 1904 | + | if p.Width, err = r.getUint16(); err != nil { | |||
| 1905 | + | return p, err | |||
| 1906 | + | } | |||
| 1907 | + | if p.Height, err = r.getUint16(); err != nil { | |||
| 1908 | + | return p, err | |||
| 1909 | + | } | |||
| 1910 | + | count, err := r.getUint32() | |||
| 1911 | + | if err != nil { | |||
| 1912 | + | return p, err | |||
| 1913 | + | } | |||
| 1914 | + | if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) { | |||
| 1915 | + | return p, ErrRemotePreviewTooLarge | |||
| 1916 | + | } | |||
| 1917 | + | if count != 0 { | |||
| 1918 | + | p.Cells = make([]renderer.Cell, 0, int(count)) | |||
| 1919 | + | for range int(count) { | |||
| 1920 | + | runeValue, e := r.getUint32() | |||
| 1921 | + | if e != nil { | |||
| 1922 | + | return p, e | |||
| 1923 | + | } | |||
| 1924 | + | flags, e := r.getUint8() | |||
| 1925 | + | if e != nil { | |||
| 1926 | + | return p, e | |||
| 1927 | + | } | |||
| 1928 | + | if flags&^uint8(1) != 0 { | |||
| 1929 | + | return p, ErrInvalidRemotePreview | |||
| 1930 | + | } | |||
| 1931 | + | style, e := getPreviewStyle(&r) | |||
| 1932 | + | if e != nil { | |||
| 1933 | + | return p, e | |||
| 1934 | + | } | |||
| 1935 | + | p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style}) | |||
| 1936 | + | } | |||
| 1937 | + | } | |||
| 1938 | + | if err := r.done(); err != nil { | |||
| 1939 | + | return p, err | |||
| 1940 | + | } | |||
| 1941 | + | if err := ValidateRemotePreview(p); err != nil { | |||
| 1942 | + | return p, err | |||
| 1943 | + | } | |||
| 1944 | + | return p, nil | |||
| 1945 | + | } | |||
M internal/usecase/daemon/picker.go +10 -3
| @@ -534,12 +534,19 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||
| 534 | 534 | preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height) | |
| 535 | 535 | if err != nil { | |
| 536 | 536 | ac.overlays.pickerMu.Lock() | |
| 537 | + | matching := false | |
| 537 | 538 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | |
| 538 | - | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |
| 539 | + | selected, stillSelected := ac.overlays.picker.Selected() | |
| 540 | + | matching = stillSelected && pickerTargetsEqual(selected, target) | |
| 541 | + | if matching { | |
| 542 | + | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |
| 543 | + | } | |
| 539 | 544 | } | |
| 540 | 545 | ac.overlays.pickerMu.Unlock() | |
| 541 | - | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 542 | - | d.invalidateRender(sess, ac, false, "remote picker preview") | |
| 546 | + | if matching { | |
| 547 | + | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 548 | + | d.invalidateRender(sess, ac, false, "remote picker preview") | |
| 549 | + | } | |
| 543 | 550 | } | |
| 544 | 551 | return | |
| 545 | 552 | } | |
| @@ -534,12 +534,19 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||||
| 534 | preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height) | 534 | preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height) | ||
| 535 | if err != nil { | 535 | if err != nil { | ||
| 536 | ac.overlays.pickerMu.Lock() | 536 | ac.overlays.pickerMu.Lock() | ||
| 537 | + | matching := false | |||
| 537 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | 538 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | ||
| 538 | - | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | 539 | + | selected, stillSelected := ac.overlays.picker.Selected() |
| 540 | + | matching = stillSelected && pickerTargetsEqual(selected, target) | |||
| 541 | + | if matching { | |||
| 542 | + | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |||
| 543 | + | } | |||
| 539 | } | 544 | } | ||
| 540 | ac.overlays.pickerMu.Unlock() | 545 | ac.overlays.pickerMu.Unlock() | ||
| 541 | - | if sess := ac.currentAttachmentSession(); sess != nil { | 546 | + | if matching { |
| 542 | - | d.invalidateRender(sess, ac, false, "remote picker preview") | 547 | + | if sess := ac.currentAttachmentSession(); sess != nil { |
| 548 | + | d.invalidateRender(sess, ac, false, "remote picker preview") | |||
| 549 | + | } | |||
| 543 | } | 550 | } | ||
| 544 | return | 551 | return | ||
| 545 | } | 552 | } | ||
M internal/usecase/daemon/remote_preview_cache_test.go +42 -0
| @@ -380,6 +380,48 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |||
| 380 | 380 | require.Equal(t, 2, client.Calls()) | |
| 381 | 381 | } | |
| 382 | 382 | ||
| 383 | + | func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) { | |
| 384 | + | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 385 | + | firstTarget := remotePreviewCacheTarget() | |
| 386 | + | secondTarget := firstTarget | |
| 387 | + | secondTarget.LifecycleID[0]++ | |
| 388 | + | secondTarget.LiveTabID = "tab-2" | |
| 389 | + | client := &remotePreviewTestClient{ | |
| 390 | + | err: errors.New("remote unavailable"), | |
| 391 | + | started: make(chan struct{}), | |
| 392 | + | release: make(chan struct{}), | |
| 393 | + | } | |
| 394 | + | d.remotePreviewClient = client | |
| 395 | + | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 396 | + | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |
| 397 | + | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |
| 398 | + | model := picker.New([]picker.SessionView{ | |
| 399 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 400 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |
| 401 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |
| 402 | + | selected, ok := model.Selected() | |
| 403 | + | require.True(t, ok) | |
| 404 | + | ac.overlays.pickerMu.Lock() | |
| 405 | + | ac.overlays.picker = model | |
| 406 | + | ac.overlays.pickerIntent = pickerNavigate | |
| 407 | + | ac.overlays.pickerPreviewGeneration = 1 | |
| 408 | + | ac.overlays.pickerMu.Unlock() | |
| 409 | + | d.startRemotePickerPreview(ac, selected, 1) | |
| 410 | + | <-client.started | |
| 411 | + | ||
| 412 | + | ac.overlays.pickerMu.Lock() | |
| 413 | + | ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}} | |
| 414 | + | model.Down() | |
| 415 | + | ac.overlays.pickerMu.Unlock() | |
| 416 | + | close(client.release) | |
| 417 | + | ||
| 418 | + | require.Eventually(t, func() bool { | |
| 419 | + | ac.overlays.pickerMu.Lock() | |
| 420 | + | defer ac.overlays.pickerMu.Unlock() | |
| 421 | + | return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k' | |
| 422 | + | }, time.Second, time.Millisecond) | |
| 423 | + | } | |
| 424 | + | ||
| 383 | 425 | func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | |
| 384 | 426 | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |
| 385 | 427 | target := remotePreviewCacheTarget() | |
| @@ -380,6 +380,48 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) { | |||||
| 380 | require.Equal(t, 2, client.Calls()) | 380 | require.Equal(t, 2, client.Calls()) | ||
| 381 | } | 381 | } | ||
| 382 | 382 | ||||
| 383 | + | func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) { | |||
| 384 | + | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | |||
| 385 | + | firstTarget := remotePreviewCacheTarget() | |||
| 386 | + | secondTarget := firstTarget | |||
| 387 | + | secondTarget.LifecycleID[0]++ | |||
| 388 | + | secondTarget.LiveTabID = "tab-2" | |||
| 389 | + | client := &remotePreviewTestClient{ | |||
| 390 | + | err: errors.New("remote unavailable"), | |||
| 391 | + | started: make(chan struct{}), | |||
| 392 | + | release: make(chan struct{}), | |||
| 393 | + | } | |||
| 394 | + | d.remotePreviewClient = client | |||
| 395 | + | _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |||
| 396 | + | firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin} | |||
| 397 | + | secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin} | |||
| 398 | + | model := picker.New([]picker.SessionView{ | |||
| 399 | + | {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |||
| 400 | + | {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true}, | |||
| 401 | + | }, picker.SelectionConfig{Mode: picker.SelectNavigationTab}) | |||
| 402 | + | selected, ok := model.Selected() | |||
| 403 | + | require.True(t, ok) | |||
| 404 | + | ac.overlays.pickerMu.Lock() | |||
| 405 | + | ac.overlays.picker = model | |||
| 406 | + | ac.overlays.pickerIntent = pickerNavigate | |||
| 407 | + | ac.overlays.pickerPreviewGeneration = 1 | |||
| 408 | + | ac.overlays.pickerMu.Unlock() | |||
| 409 | + | d.startRemotePickerPreview(ac, selected, 1) | |||
| 410 | + | <-client.started | |||
| 411 | + | ||||
| 412 | + | ac.overlays.pickerMu.Lock() | |||
| 413 | + | ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}} | |||
| 414 | + | model.Down() | |||
| 415 | + | ac.overlays.pickerMu.Unlock() | |||
| 416 | + | close(client.release) | |||
| 417 | + | ||||
| 418 | + | require.Eventually(t, func() bool { | |||
| 419 | + | ac.overlays.pickerMu.Lock() | |||
| 420 | + | defer ac.overlays.pickerMu.Unlock() | |||
| 421 | + | return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k' | |||
| 422 | + | }, time.Second, time.Millisecond) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 383 | func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | 425 | func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) { | ||
| 384 | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | 426 | d := newTestDaemon(t, nil, immediateRemotePreviewClock{}) | ||
| 385 | target := remotePreviewCacheTarget() | 427 | target := remotePreviewCacheTarget() | ||
M internal/usecase/picker/remote_parity_test.go +4 -2
| @@ -35,11 +35,12 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||
| 35 | 35 | expected := []struct { | |
| 36 | 36 | name string | |
| 37 | 37 | session domain.SessionID | |
| 38 | + | endpoint string | |
| 38 | 39 | lifecycle domain.SessionLifecycleID | |
| 39 | 40 | tabID domain.TabStableID | |
| 40 | 41 | }{ | |
| 41 | - | {name: "first", session: firstKey.ID(), lifecycle: firstLifecycle, tabID: "tab-a"}, | |
| 42 | - | {name: "second", session: secondKey.ID(), lifecycle: secondLifecycle, tabID: "tab-b"}, | |
| 42 | + | {name: "first", session: firstKey.ID(), endpoint: "arch", lifecycle: firstLifecycle, tabID: "tab-a"}, | |
| 43 | + | {name: "second", session: secondKey.ID(), endpoint: "mule", lifecycle: secondLifecycle, tabID: "tab-b"}, | |
| 43 | 44 | } | |
| 44 | 45 | for i, want := range expected { | |
| 45 | 46 | t.Run(want.name, func(t *testing.T) { | |
| @@ -47,6 +48,7 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||
| 47 | 48 | require.True(t, ok) | |
| 48 | 49 | require.Equal(t, want.session, selected.Session) | |
| 49 | 50 | require.NotNil(t, selected.RemoteTarget) | |
| 51 | + | require.Equal(t, want.endpoint, selected.RemoteTarget.Endpoint) | |
| 50 | 52 | require.Equal(t, want.lifecycle, selected.RemoteTarget.LifecycleID) | |
| 51 | 53 | require.Equal(t, want.tabID, selected.RemoteTarget.LiveTabID) | |
| 52 | 54 | }) | |
| @@ -35,11 +35,12 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||||
| 35 | expected := []struct { | 35 | expected := []struct { | ||
| 36 | name string | 36 | name string | ||
| 37 | session domain.SessionID | 37 | session domain.SessionID | ||
| 38 | + | endpoint string | |||
| 38 | lifecycle domain.SessionLifecycleID | 39 | lifecycle domain.SessionLifecycleID | ||
| 39 | tabID domain.TabStableID | 40 | tabID domain.TabStableID | ||
| 40 | }{ | 41 | }{ | ||
| 41 | - | {name: "first", session: firstKey.ID(), lifecycle: firstLifecycle, tabID: "tab-a"}, | 42 | + | {name: "first", session: firstKey.ID(), endpoint: "arch", lifecycle: firstLifecycle, tabID: "tab-a"}, |
| 42 | - | {name: "second", session: secondKey.ID(), lifecycle: secondLifecycle, tabID: "tab-b"}, | 43 | + | {name: "second", session: secondKey.ID(), endpoint: "mule", lifecycle: secondLifecycle, tabID: "tab-b"}, |
| 43 | } | 44 | } | ||
| 44 | for i, want := range expected { | 45 | for i, want := range expected { | ||
| 45 | t.Run(want.name, func(t *testing.T) { | 46 | t.Run(want.name, func(t *testing.T) { | ||
| @@ -47,6 +48,7 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) | |||||
| 47 | require.True(t, ok) | 48 | require.True(t, ok) | ||
| 48 | require.Equal(t, want.session, selected.Session) | 49 | require.Equal(t, want.session, selected.Session) | ||
| 49 | require.NotNil(t, selected.RemoteTarget) | 50 | require.NotNil(t, selected.RemoteTarget) | ||
| 51 | + | require.Equal(t, want.endpoint, selected.RemoteTarget.Endpoint) | |||
| 50 | require.Equal(t, want.lifecycle, selected.RemoteTarget.LifecycleID) | 52 | require.Equal(t, want.lifecycle, selected.RemoteTarget.LifecycleID) | ||
| 51 | require.Equal(t, want.tabID, selected.RemoteTarget.LiveTabID) | 53 | require.Equal(t, want.tabID, selected.RemoteTarget.LiveTabID) | ||
| 52 | }) | 54 | }) | ||