detached fe22594

fix(remote): address follow-up picker review findings

commit
fe225948dcc40d8b274d794ba155e12e64147304
author
brice · · 2026-08-05 13:24 UTC
parent
9aa4936
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +387 -305
M internal/ports/frame.go +43 -1
@@ -1,6 +1,11 @@
11 package ports
22
3-import "time"
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
49
510 // HandshakeTimeout bounds every transport handshake from connect through the
611 // first committed publication.
@@ -55,3 +60,40 @@ type Frame struct {
5560 Type MsgType
5661 Payload []byte
5762 }
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 ports1 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 the10 // 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 MsgType60 Type MsgType
56 Payload []byte61 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
33 import (
44 "errors"
55 "math"
6+ "unicode"
67 "unicode/utf8"
78
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/pkg/renderer"
1011 )
1112
12-// RemotePreviewSchemaVersion is independent from the attachment IPC version.
13-const RemotePreviewSchemaVersion uint16 = 1
14-
1513 const (
1614 RemotePreviewMaxWidth = 256
1715 RemotePreviewMaxHeight = 128
@@ -19,40 +17,6 @@ const (
1917 RemotePreviewMaxBytes = 1 << 20
2018 )
2119
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-
5620 var (
5721 ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request")
5822 ErrInvalidRemotePreview = errors.New("ports: invalid remote preview")
@@ -101,7 +65,7 @@ func ValidateRemotePreview(preview RemotePreview) error {
10165 }
10266 width := int(preview.Width)
10367 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) {
10569 return ErrInvalidRemotePreview
10670 }
10771 rowStart := (i / width) * width
@@ -125,267 +89,6 @@ func validRemotePreviewStyle(style renderer.Style) bool {
12589 style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16
12690 }
12791
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-
38992 func (p RemotePreview) FrameRows() [][]renderer.Cell {
39093 if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) {
39194 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 = 25614 RemotePreviewMaxWidth = 256
17 RemotePreviewMaxHeight = 12815 RemotePreviewMaxHeight = 128
@@ -19,40 +17,6 @@ const (
19 RemotePreviewMaxBytes = 1 << 2017 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 ErrInvalidRemotePreview69 return ErrInvalidRemotePreview
106 }70 }
107 rowStart := (i / width) * width71 rowStart := (i / width) * width
@@ -125,267 +89,6 @@ func validRemotePreviewStyle(style renderer.Style) bool {
125 style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt1689 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 nil94 return nil
M internal/ports/remote_preview_hardening_test.go +18 -0
@@ -116,6 +116,24 @@ func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) {
116116 }
117117 }
118118
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+
119137 func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) {
120138 target := previewTargetForTest()
121139 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) {
16751675 }
16761676 return Sessions{Sessions: sessions}, nil
16771677 }
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}, nil1676 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
534534 preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
535535 if err != nil {
536536 ac.overlays.pickerMu.Lock()
537+ matching := false
537538 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+ }
539544 }
540545 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+ }
543550 }
544551 return
545552 }
@@ -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 return551 return
545 }552 }
M internal/usecase/daemon/remote_preview_cache_test.go +42 -0
@@ -380,6 +380,48 @@ func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
380380 require.Equal(t, 2, client.Calls())
381381 }
382382
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+
383425 func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
384426 d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
385427 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)
3535 expected := []struct {
3636 name string
3737 session domain.SessionID
38+ endpoint string
3839 lifecycle domain.SessionLifecycleID
3940 tabID domain.TabStableID
4041 }{
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"},
4344 }
4445 for i, want := range expected {
4546 t.Run(want.name, func(t *testing.T) {
@@ -47,6 +48,7 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T)
4748 require.True(t, ok)
4849 require.Equal(t, want.session, selected.Session)
4950 require.NotNil(t, selected.RemoteTarget)
51+ require.Equal(t, want.endpoint, selected.RemoteTarget.Endpoint)
5052 require.Equal(t, want.lifecycle, selected.RemoteTarget.LifecycleID)
5153 require.Equal(t, want.tabID, selected.RemoteTarget.LiveTabID)
5254 })
@@ -35,11 +35,12 @@ func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T)
35 expected := []struct {35 expected := []struct {
36 name string36 name string
37 session domain.SessionID37 session domain.SessionID
38+ endpoint string
38 lifecycle domain.SessionLifecycleID39 lifecycle domain.SessionLifecycleID
39 tabID domain.TabStableID40 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 })

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close