detached 383f6ac

feat(protocol): add strict kitty graphics adapter

commit
383f6ac5d59c8c85178c3c5232ed9346d3434a04
author
brice · · 2026-08-20 14:43 UTC
committer
brice · · 2026-08-20 14:46 UTC
parent
65370d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1899 -0

this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.

A protocol/kittygraphics/base64.go +24 -0
@@ -0,0 +1,24 @@
1+package kittygraphics
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+)
7+
8+// DecodeBase64 accepts both padded standard base64 and unpadded raw standard
9+// base64. It deliberately does not accept URL-safe alphabets or non-base64
10+// whitespace other than the CR/LF ignored by the standard encoding.
11+func DecodeBase64(encoded []byte) ([]byte, error) {
12+ decoded, err := base64.StdEncoding.DecodeString(string(encoded))
13+ if err == nil {
14+ return decoded, nil
15+ }
16+ decoded, rawErr := base64.RawStdEncoding.DecodeString(string(encoded))
17+ if rawErr == nil {
18+ return decoded, nil
19+ }
20+ return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)
21+}
22+
23+// DecodePayload is a descriptive alias for DecodeBase64.
24+func DecodePayload(encoded []byte) ([]byte, error) { return DecodeBase64(encoded) }
@@ -0,0 +1,24 @@
1+package kittygraphics
2+
3+import (
4+ "encoding/base64"
5+ "fmt"
6+)
7+
8+// DecodeBase64 accepts both padded standard base64 and unpadded raw standard
9+// base64. It deliberately does not accept URL-safe alphabets or non-base64
10+// whitespace other than the CR/LF ignored by the standard encoding.
11+func DecodeBase64(encoded []byte) ([]byte, error) {
12+ decoded, err := base64.StdEncoding.DecodeString(string(encoded))
13+ if err == nil {
14+ return decoded, nil
15+ }
16+ decoded, rawErr := base64.RawStdEncoding.DecodeString(string(encoded))
17+ if rawErr == nil {
18+ return decoded, nil
19+ }
20+ return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)
21+}
22+
23+// DecodePayload is a descriptive alias for DecodeBase64.
24+func DecodePayload(encoded []byte) ([]byte, error) { return DecodeBase64(encoded) }
A protocol/kittygraphics/controls.go +236 -0
@@ -0,0 +1,236 @@
1+package kittygraphics
2+
3+import (
4+ "fmt"
5+)
6+
7+// ParseControls parses the comma-separated control header of an APC.
8+func ParseControls(header []byte) (Controls, error) { return parseControls(header) }
9+
10+// ParseHeader is an alias for ParseControls.
11+func ParseHeader(header []byte) (Controls, error) { return ParseControls(header) }
12+
13+func parseControls(header []byte) (Controls, error) {
14+ var c Controls
15+ if len(header) == 0 {
16+ return c, nil
17+ }
18+ seen := make(map[byte]struct{}, 8)
19+ for _, field := range splitComma(header) {
20+ if len(field) < 3 || field[1] != '=' {
21+ return Controls{}, fmt.Errorf("%w: control %q", ErrInvalidCommand, field)
22+ }
23+ key := field[0]
24+ if _, ok := seen[key]; ok {
25+ return Controls{}, fmt.Errorf("%w: %c", ErrDuplicateControl, key)
26+ }
27+ seen[key] = struct{}{}
28+ value := field[2:]
29+ if err := parseControl(&c, key, value); err != nil {
30+ return Controls{}, err
31+ }
32+ }
33+ if c.HasAction && !c.Action.Valid() {
34+ return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)
35+ }
36+ if c.HasQuiet && c.Quiet > QuietAll {
37+ return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)
38+ }
39+ if c.HasMore && c.More > 1 {
40+ return Controls{}, fmt.Errorf("%w: m=%d", ErrInvalidCommand, c.More)
41+ }
42+ if c.HasCursor && c.Cursor > 1 {
43+ return Controls{}, fmt.Errorf("%w: C=%d", ErrInvalidCommand, c.Cursor)
44+ }
45+ return c, nil
46+}
47+
48+func splitComma(data []byte) [][]byte {
49+ var out [][]byte
50+ start := 0
51+ for i, b := range data {
52+ if b == ',' {
53+ out = append(out, data[start:i])
54+ start = i + 1
55+ }
56+ }
57+ return append(out, data[start:])
58+}
59+
60+func parseControl(c *Controls, key byte, value []byte) error {
61+ if len(value) == 0 {
62+ return fmt.Errorf("%w: empty %c value", ErrInvalidInteger, key)
63+ }
64+ switch ControlKey(key) {
65+ case ControlAction:
66+ if len(value) != 1 {
67+ return fmt.Errorf("%w: action %q", ErrUnknownAction, value)
68+ }
69+ c.Action, c.HasAction = Action(value[0]), true
70+ case ControlCompression:
71+ n, err := parseUint(value)
72+ if err != nil {
73+ return fieldError(key, err)
74+ }
75+ c.Compression, c.HasCompression = n, true
76+ case ControlFormat:
77+ n, err := parseUint(value)
78+ if err != nil {
79+ return fieldError(key, err)
80+ }
81+ f := Format(n)
82+ if !f.Valid() {
83+ return fmt.Errorf("%w: f=%d", ErrInvalidCommand, n)
84+ }
85+ c.Format, c.HasFormat = f, true
86+ case ControlImageID:
87+ return setID(&c.ImageID, &c.HasImageID, key, value)
88+ case ControlImageNumber:
89+ return setID(&c.ImageNumber, &c.HasImageNumber, key, value)
90+ case ControlMore:
91+ return setUint(&c.More, &c.HasMore, key, value)
92+ case ControlQuiet:
93+ var n uint64
94+ if err := setUint(&n, nil, key, value); err != nil {
95+ return err
96+ }
97+ c.Quiet, c.HasQuiet = Quiet(n), true
98+ case ControlWidth:
99+ return setUint(&c.Width, &c.HasWidth, key, value)
100+ case ControlHeight:
101+ return setUint(&c.Height, &c.HasHeight, key, value)
102+ case ControlColumns:
103+ return setUint(&c.Columns, &c.HasColumns, key, value)
104+ case ControlRows:
105+ return setUint(&c.Rows, &c.HasRows, key, value)
106+ case ControlX:
107+ return setUint(&c.X, &c.HasX, key, value)
108+ case ControlY:
109+ return setUint(&c.Y, &c.HasY, key, value)
110+ case ControlSourceWidth:
111+ return setUint(&c.SourceWidth, &c.HasSourceWidth, key, value)
112+ case ControlSourceHeight:
113+ return setUint(&c.SourceHeight, &c.HasSourceHeight, key, value)
114+ case ControlLayer:
115+ return setInt(&c.Layer, &c.HasLayer, key, value)
116+ case ControlPlacementID:
117+ return setID(&c.PlacementID, &c.HasPlacementID, key, value)
118+ case ControlDelete:
119+ if len(value) != 1 {
120+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
121+ }
122+ d := DeleteTarget(value[0])
123+ switch d {
124+ case DeleteImage, DeleteImageNumber, DeletePlacement, DeleteAll, DeleteAllImages, DeleteAllPlacements:
125+ c.Delete, c.HasDelete = d, true
126+ default:
127+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
128+ }
129+ case ControlTransmission:
130+ if len(value) != 1 || Transmission(value[0]) != TransmissionDirect {
131+ return fmt.Errorf("%w: t=%q", ErrUnsupported, value)
132+ }
133+ c.Transmission, c.HasTransmission = TransmissionDirect, true
134+ case ControlCursor:
135+ return setUint(&c.Cursor, &c.HasCursor, key, value)
136+ case ControlSourceX:
137+ return setUint(&c.SourceX, &c.HasSourceX, key, value)
138+ case ControlSourceY:
139+ return setUint(&c.SourceY, &c.HasSourceY, key, value)
140+ case ControlCellOffsetX:
141+ return setInt(&c.CellOffsetX, &c.HasCellOffsetX, key, value)
142+ case ControlCellOffsetY:
143+ return setInt(&c.CellOffsetY, &c.HasCellOffsetY, key, value)
144+ case ControlParent:
145+ return setUint(&c.Parent, &c.HasParent, key, value)
146+ default:
147+ return fmt.Errorf("%w: %c", ErrUnknownControl, key)
148+ }
149+ return nil
150+}
151+
152+func fieldError(key byte, err error) error {
153+ return fmt.Errorf("%w: %c: %w", ErrInvalidCommand, key, err)
154+}
155+
156+func setID(dst *uint64, present *bool, key byte, value []byte) error {
157+ if err := setUint(dst, present, key, value); err != nil {
158+ return err
159+ }
160+ if *dst > uint64(^uint32(0)) {
161+ return fieldError(key, ErrIntegerOverflow)
162+ }
163+ return nil
164+}
165+
166+func setUint(dst *uint64, present *bool, key byte, value []byte) error {
167+ n, err := parseUint(value)
168+ if err != nil {
169+ return fieldError(key, err)
170+ }
171+ *dst = n
172+ if present != nil {
173+ *present = true
174+ }
175+ return nil
176+}
177+
178+func setInt(dst *int64, present *bool, key byte, value []byte) error {
179+ n, err := parseInt(value)
180+ if err != nil {
181+ return fieldError(key, err)
182+ }
183+ *dst = n
184+ if present != nil {
185+ *present = true
186+ }
187+ return nil
188+}
189+
190+func parseUint(value []byte) (uint64, error) {
191+ if len(value) == 0 {
192+ return 0, ErrInvalidInteger
193+ }
194+ var n uint64
195+ for _, b := range value {
196+ if b < '0' || b > '9' {
197+ return 0, ErrInvalidInteger
198+ }
199+ digit := uint64(b - '0')
200+ if n > (^uint64(0)-digit)/10 {
201+ return 0, ErrIntegerOverflow
202+ }
203+ n = n*10 + digit
204+ }
205+ return n, nil
206+}
207+
208+func parseInt(value []byte) (int64, error) {
209+ if len(value) == 0 {
210+ return 0, ErrInvalidInteger
211+ }
212+ negative := value[0] == '-'
213+ if negative {
214+ value = value[1:]
215+ if len(value) == 0 {
216+ return 0, ErrInvalidInteger
217+ }
218+ }
219+ n, err := parseUint(value)
220+ if err != nil {
221+ return 0, err
222+ }
223+ if negative {
224+ if n > uint64(1)<<63 {
225+ return 0, ErrIntegerOverflow
226+ }
227+ if n == uint64(1)<<63 {
228+ return -1 << 63, nil
229+ }
230+ return -int64(n), nil
231+ }
232+ if n > uint64(^uint64(0)>>1) {
233+ return 0, ErrIntegerOverflow
234+ }
235+ return int64(n), nil
236+}
@@ -0,0 +1,236 @@
1+package kittygraphics
2+
3+import (
4+ "fmt"
5+)
6+
7+// ParseControls parses the comma-separated control header of an APC.
8+func ParseControls(header []byte) (Controls, error) { return parseControls(header) }
9+
10+// ParseHeader is an alias for ParseControls.
11+func ParseHeader(header []byte) (Controls, error) { return ParseControls(header) }
12+
13+func parseControls(header []byte) (Controls, error) {
14+ var c Controls
15+ if len(header) == 0 {
16+ return c, nil
17+ }
18+ seen := make(map[byte]struct{}, 8)
19+ for _, field := range splitComma(header) {
20+ if len(field) < 3 || field[1] != '=' {
21+ return Controls{}, fmt.Errorf("%w: control %q", ErrInvalidCommand, field)
22+ }
23+ key := field[0]
24+ if _, ok := seen[key]; ok {
25+ return Controls{}, fmt.Errorf("%w: %c", ErrDuplicateControl, key)
26+ }
27+ seen[key] = struct{}{}
28+ value := field[2:]
29+ if err := parseControl(&c, key, value); err != nil {
30+ return Controls{}, err
31+ }
32+ }
33+ if c.HasAction && !c.Action.Valid() {
34+ return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)
35+ }
36+ if c.HasQuiet && c.Quiet > QuietAll {
37+ return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)
38+ }
39+ if c.HasMore && c.More > 1 {
40+ return Controls{}, fmt.Errorf("%w: m=%d", ErrInvalidCommand, c.More)
41+ }
42+ if c.HasCursor && c.Cursor > 1 {
43+ return Controls{}, fmt.Errorf("%w: C=%d", ErrInvalidCommand, c.Cursor)
44+ }
45+ return c, nil
46+}
47+
48+func splitComma(data []byte) [][]byte {
49+ var out [][]byte
50+ start := 0
51+ for i, b := range data {
52+ if b == ',' {
53+ out = append(out, data[start:i])
54+ start = i + 1
55+ }
56+ }
57+ return append(out, data[start:])
58+}
59+
60+func parseControl(c *Controls, key byte, value []byte) error {
61+ if len(value) == 0 {
62+ return fmt.Errorf("%w: empty %c value", ErrInvalidInteger, key)
63+ }
64+ switch ControlKey(key) {
65+ case ControlAction:
66+ if len(value) != 1 {
67+ return fmt.Errorf("%w: action %q", ErrUnknownAction, value)
68+ }
69+ c.Action, c.HasAction = Action(value[0]), true
70+ case ControlCompression:
71+ n, err := parseUint(value)
72+ if err != nil {
73+ return fieldError(key, err)
74+ }
75+ c.Compression, c.HasCompression = n, true
76+ case ControlFormat:
77+ n, err := parseUint(value)
78+ if err != nil {
79+ return fieldError(key, err)
80+ }
81+ f := Format(n)
82+ if !f.Valid() {
83+ return fmt.Errorf("%w: f=%d", ErrInvalidCommand, n)
84+ }
85+ c.Format, c.HasFormat = f, true
86+ case ControlImageID:
87+ return setID(&c.ImageID, &c.HasImageID, key, value)
88+ case ControlImageNumber:
89+ return setID(&c.ImageNumber, &c.HasImageNumber, key, value)
90+ case ControlMore:
91+ return setUint(&c.More, &c.HasMore, key, value)
92+ case ControlQuiet:
93+ var n uint64
94+ if err := setUint(&n, nil, key, value); err != nil {
95+ return err
96+ }
97+ c.Quiet, c.HasQuiet = Quiet(n), true
98+ case ControlWidth:
99+ return setUint(&c.Width, &c.HasWidth, key, value)
100+ case ControlHeight:
101+ return setUint(&c.Height, &c.HasHeight, key, value)
102+ case ControlColumns:
103+ return setUint(&c.Columns, &c.HasColumns, key, value)
104+ case ControlRows:
105+ return setUint(&c.Rows, &c.HasRows, key, value)
106+ case ControlX:
107+ return setUint(&c.X, &c.HasX, key, value)
108+ case ControlY:
109+ return setUint(&c.Y, &c.HasY, key, value)
110+ case ControlSourceWidth:
111+ return setUint(&c.SourceWidth, &c.HasSourceWidth, key, value)
112+ case ControlSourceHeight:
113+ return setUint(&c.SourceHeight, &c.HasSourceHeight, key, value)
114+ case ControlLayer:
115+ return setInt(&c.Layer, &c.HasLayer, key, value)
116+ case ControlPlacementID:
117+ return setID(&c.PlacementID, &c.HasPlacementID, key, value)
118+ case ControlDelete:
119+ if len(value) != 1 {
120+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
121+ }
122+ d := DeleteTarget(value[0])
123+ switch d {
124+ case DeleteImage, DeleteImageNumber, DeletePlacement, DeleteAll, DeleteAllImages, DeleteAllPlacements:
125+ c.Delete, c.HasDelete = d, true
126+ default:
127+ return fmt.Errorf("%w: d=%q", ErrInvalidCommand, value)
128+ }
129+ case ControlTransmission:
130+ if len(value) != 1 || Transmission(value[0]) != TransmissionDirect {
131+ return fmt.Errorf("%w: t=%q", ErrUnsupported, value)
132+ }
133+ c.Transmission, c.HasTransmission = TransmissionDirect, true
134+ case ControlCursor:
135+ return setUint(&c.Cursor, &c.HasCursor, key, value)
136+ case ControlSourceX:
137+ return setUint(&c.SourceX, &c.HasSourceX, key, value)
138+ case ControlSourceY:
139+ return setUint(&c.SourceY, &c.HasSourceY, key, value)
140+ case ControlCellOffsetX:
141+ return setInt(&c.CellOffsetX, &c.HasCellOffsetX, key, value)
142+ case ControlCellOffsetY:
143+ return setInt(&c.CellOffsetY, &c.HasCellOffsetY, key, value)
144+ case ControlParent:
145+ return setUint(&c.Parent, &c.HasParent, key, value)
146+ default:
147+ return fmt.Errorf("%w: %c", ErrUnknownControl, key)
148+ }
149+ return nil
150+}
151+
152+func fieldError(key byte, err error) error {
153+ return fmt.Errorf("%w: %c: %w", ErrInvalidCommand, key, err)
154+}
155+
156+func setID(dst *uint64, present *bool, key byte, value []byte) error {
157+ if err := setUint(dst, present, key, value); err != nil {
158+ return err
159+ }
160+ if *dst > uint64(^uint32(0)) {
161+ return fieldError(key, ErrIntegerOverflow)
162+ }
163+ return nil
164+}
165+
166+func setUint(dst *uint64, present *bool, key byte, value []byte) error {
167+ n, err := parseUint(value)
168+ if err != nil {
169+ return fieldError(key, err)
170+ }
171+ *dst = n
172+ if present != nil {
173+ *present = true
174+ }
175+ return nil
176+}
177+
178+func setInt(dst *int64, present *bool, key byte, value []byte) error {
179+ n, err := parseInt(value)
180+ if err != nil {
181+ return fieldError(key, err)
182+ }
183+ *dst = n
184+ if present != nil {
185+ *present = true
186+ }
187+ return nil
188+}
189+
190+func parseUint(value []byte) (uint64, error) {
191+ if len(value) == 0 {
192+ return 0, ErrInvalidInteger
193+ }
194+ var n uint64
195+ for _, b := range value {
196+ if b < '0' || b > '9' {
197+ return 0, ErrInvalidInteger
198+ }
199+ digit := uint64(b - '0')
200+ if n > (^uint64(0)-digit)/10 {
201+ return 0, ErrIntegerOverflow
202+ }
203+ n = n*10 + digit
204+ }
205+ return n, nil
206+}
207+
208+func parseInt(value []byte) (int64, error) {
209+ if len(value) == 0 {
210+ return 0, ErrInvalidInteger
211+ }
212+ negative := value[0] == '-'
213+ if negative {
214+ value = value[1:]
215+ if len(value) == 0 {
216+ return 0, ErrInvalidInteger
217+ }
218+ }
219+ n, err := parseUint(value)
220+ if err != nil {
221+ return 0, err
222+ }
223+ if negative {
224+ if n > uint64(1)<<63 {
225+ return 0, ErrIntegerOverflow
226+ }
227+ if n == uint64(1)<<63 {
228+ return -1 << 63, nil
229+ }
230+ return -int64(n), nil
231+ }
232+ if n > uint64(^uint64(0)>>1) {
233+ return 0, ErrIntegerOverflow
234+ }
235+ return int64(n), nil
236+}
A protocol/kittygraphics/doc.go +5 -0
@@ -0,0 +1,5 @@
1+// Package kittygraphics implements the bounded Kitty graphics protocol
2+// adapter. It parses APC graphics commands independently of terminal text
3+// state and applies accepted image and placement operations to a graphics
4+// scene.
5+package kittygraphics
@@ -0,0 +1,5 @@
1+// Package kittygraphics implements the bounded Kitty graphics protocol
2+// adapter. It parses APC graphics commands independently of terminal text
3+// state and applies accepted image and placement operations to a graphics
4+// scene.
5+package kittygraphics
A protocol/kittygraphics/kittygraphics.go +467 -0
@@ -0,0 +1,467 @@
1+package kittygraphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "sync"
7+
8+ "github.com/bnema/vev-vt/graphics"
9+)
10+
11+var (
12+ ErrInvalidAPC = errors.New("invalid kitty graphics APC")
13+ ErrAPCTooLarge = errors.New("kitty graphics APC is too large")
14+ ErrAPCTruncated = errors.New("truncated kitty graphics APC")
15+ ErrInvalidCommand = errors.New("invalid kitty graphics command")
16+ ErrUnknownAction = errors.New("unknown kitty graphics action")
17+ ErrUnknownControl = errors.New("unknown kitty graphics control")
18+ ErrDuplicateControl = errors.New("duplicate kitty graphics control")
19+ ErrInvalidInteger = errors.New("invalid kitty graphics integer")
20+ ErrIntegerOverflow = errors.New("kitty graphics integer overflow")
21+ ErrInvalidBase64 = errors.New("invalid kitty graphics base64")
22+ ErrInvalidPNG = errors.New("invalid kitty graphics PNG")
23+ ErrPayloadTooLarge = errors.New("kitty graphics payload is too large")
24+ ErrTooManyChunks = errors.New("too many kitty graphics continuation chunks")
25+ ErrInterleavedUpload = errors.New("interleaved kitty graphics upload")
26+ ErrUnsupported = errors.New("unsupported kitty graphics command")
27+ ErrImageNotFound = errors.New("kitty graphics image not found")
28+ ErrPlacementNotFound = errors.New("kitty graphics placement not found")
29+ ErrNoScene = errors.New("kitty graphics scene is nil")
30+)
31+
32+const (
33+ // DefaultMaxAPCBytes bounds the complete framed APC, including its
34+ // controls and encoded payload.
35+ DefaultMaxAPCBytes = uint64(32 << 20)
36+ // DefaultMaxPayloadBytes bounds encoded bytes in one image, before base64
37+ // decoding. The graphics scene applies its own encoded-byte budget too.
38+ DefaultMaxPayloadBytes = uint64(24 << 20)
39+ DefaultMaxUploadBytes = uint64(16 << 20)
40+ DefaultMaxChunks = uint64(1024)
41+ DefaultMaxPixels = uint64(128 << 20)
42+ DefaultMaxDimension = uint64(1 << 20)
43+)
44+
45+// Limits bounds parser and adapter resource use. Zero values select bounded
46+// defaults. The limits are independent from the graphics scene limits: both
47+// layers enforce their budgets.
48+type Limits struct {
49+ MaxAPCBytes uint64
50+ MaxPayloadBytes uint64
51+ MaxUploadBytes uint64
52+ MaxChunks uint64
53+ MaxDecodedPixels uint64
54+ MaxDimension uint64
55+ MaxResponseBytes uint64
56+ MaxImages uint64
57+ MaxPlacements uint64
58+}
59+
60+// Config is an alias retained for callers that use constructor terminology.
61+type Config = Limits
62+
63+func normalizeLimits(l Limits) Limits {
64+ if l.MaxAPCBytes == 0 {
65+ l.MaxAPCBytes = DefaultMaxAPCBytes
66+ }
67+ if l.MaxPayloadBytes == 0 {
68+ l.MaxPayloadBytes = DefaultMaxPayloadBytes
69+ }
70+ if l.MaxUploadBytes == 0 {
71+ l.MaxUploadBytes = DefaultMaxUploadBytes
72+ }
73+ if l.MaxChunks == 0 {
74+ l.MaxChunks = DefaultMaxChunks
75+ }
76+ if l.MaxDecodedPixels == 0 {
77+ l.MaxDecodedPixels = DefaultMaxPixels
78+ }
79+ if l.MaxDimension == 0 {
80+ l.MaxDimension = DefaultMaxDimension
81+ }
82+ if l.MaxResponseBytes == 0 {
83+ l.MaxResponseBytes = 4096
84+ }
85+ if l.MaxImages == 0 {
86+ l.MaxImages = 1024
87+ }
88+ if l.MaxPlacements == 0 {
89+ l.MaxPlacements = 65536
90+ }
91+ return l
92+}
93+
94+// Action is the closed set of Kitty graphics actions understood by this
95+// adapter. The underlying byte values are the protocol values.
96+type Action byte
97+
98+const (
99+ ActionTransmit Action = 't'
100+ ActionTransmitDisplay Action = 'T'
101+ ActionPut Action = 'p'
102+ ActionQuery Action = 'q'
103+ ActionDelete Action = 'd'
104+ ActionFrame Action = 'f'
105+ ActionCompose Action = 'a'
106+
107+ // Descriptive aliases used by callers that prefer the protocol names.
108+ ActionTransmitAndDisplay = ActionTransmitDisplay
109+ ActionDisplay = ActionPut
110+)
111+
112+func (a Action) Valid() bool {
113+ switch a {
114+ case ActionTransmit, ActionTransmitDisplay, ActionPut, ActionQuery, ActionDelete, ActionFrame, ActionCompose:
115+ return true
116+ default:
117+ return false
118+ }
119+}
120+
121+func (a Action) String() string {
122+ if !a.Valid() {
123+ return "unknown"
124+ }
125+ return string([]byte{byte(a)})
126+}
127+
128+// Format is the closed set of Kitty image formats supported by this adapter.
129+type Format uint64
130+
131+const (
132+ FormatRGB Format = 24
133+ FormatRGBA Format = 32
134+ FormatPNG Format = 100
135+
136+ FormatRGB24 = FormatRGB
137+ FormatRGBA32 = FormatRGBA
138+)
139+
140+func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
141+
142+// Quiet controls protocol response emission. Kitty uses q=1 for successful
143+// operations only and q=2 for all responses.
144+type Quiet uint8
145+
146+const (
147+ QuietNever Quiet = 0
148+ QuietSuccess Quiet = 1
149+ QuietAll Quiet = 2
150+
151+ QuietNone = QuietNever
152+ QuietErrorsOnly = QuietSuccess
153+ QuietEverything = QuietAll
154+)
155+
156+// DeleteTarget is the closed set of targets accepted by a delete action.
157+type DeleteTarget byte
158+
159+const (
160+ DeleteImage DeleteTarget = 'i'
161+ DeleteImageNumber DeleteTarget = 'I'
162+ DeletePlacement DeleteTarget = 'p'
163+ DeleteAll DeleteTarget = 'a'
164+ DeleteAllImages DeleteTarget = 'A'
165+ DeleteAllPlacements DeleteTarget = 'P'
166+)
167+
168+// Transmission is the direct transmission mode. File, temporary-file and
169+// shared-memory transmissions are intentionally not accepted by this adapter.
170+type Transmission byte
171+
172+const TransmissionDirect Transmission = 'd'
173+
174+// ControlKey identifies a recognized Kitty control field.
175+type ControlKey byte
176+
177+const (
178+ ControlAction ControlKey = 'a'
179+ ControlCompression ControlKey = 'o'
180+ ControlFormat ControlKey = 'f'
181+ ControlImageID ControlKey = 'i'
182+ ControlImageNumber ControlKey = 'I'
183+ ControlMore ControlKey = 'm'
184+ ControlQuiet ControlKey = 'q'
185+ ControlWidth ControlKey = 's'
186+ ControlHeight ControlKey = 'v'
187+ ControlColumns ControlKey = 'c'
188+ ControlRows ControlKey = 'r'
189+ ControlX ControlKey = 'x'
190+ ControlY ControlKey = 'y'
191+ ControlSourceWidth ControlKey = 'w'
192+ ControlSourceHeight ControlKey = 'h'
193+ ControlLayer ControlKey = 'z'
194+ ControlPlacementID ControlKey = 'p'
195+ ControlDelete ControlKey = 'd'
196+ ControlTransmission ControlKey = 't'
197+ ControlCursor ControlKey = 'C'
198+ ControlSourceX ControlKey = 'X'
199+ ControlSourceY ControlKey = 'Y'
200+ ControlCellOffsetX ControlKey = 'H'
201+ ControlCellOffsetY ControlKey = 'V'
202+ ControlParent ControlKey = 'P'
203+)
204+
205+// Controls is the typed, closed representation of a Kitty control header.
206+// Has* fields preserve the distinction between an omitted field and zero.
207+type Controls struct {
208+ Action Action
209+ HasAction bool
210+ Compression uint64
211+ HasCompression bool
212+ Format Format
213+ HasFormat bool
214+ ImageID uint64
215+ HasImageID bool
216+ ImageNumber uint64
217+ HasImageNumber bool
218+ More uint64
219+ HasMore bool
220+ Quiet Quiet
221+ HasQuiet bool
222+ Width uint64
223+ HasWidth bool
224+ Height uint64
225+ HasHeight bool
226+ Columns uint64
227+ HasColumns bool
228+ Rows uint64
229+ HasRows bool
230+ X uint64
231+ HasX bool
232+ Y uint64
233+ HasY bool
234+ SourceWidth uint64
235+ HasSourceWidth bool
236+ SourceHeight uint64
237+ HasSourceHeight bool
238+ Layer int64
239+ HasLayer bool
240+ PlacementID uint64
241+ HasPlacementID bool
242+ Delete DeleteTarget
243+ HasDelete bool
244+ Transmission Transmission
245+ HasTransmission bool
246+ Cursor uint64
247+ HasCursor bool
248+ SourceX uint64
249+ HasSourceX bool
250+ SourceY uint64
251+ HasSourceY bool
252+ CellOffsetX int64
253+ HasCellOffsetX bool
254+ CellOffsetY int64
255+ HasCellOffsetY bool
256+ Parent uint64
257+ HasParent bool
258+}
259+
260+// Command is one complete APC graphics command. Payload is the encoded
261+// command payload and is copied from the parser input.
262+type Command struct {
263+ Controls Controls
264+ Payload []byte
265+}
266+
267+// EventKind identifies parser output. Text events contain bytes which are not
268+// Kitty graphics APCs; command events contain complete commands.
269+type EventKind uint8
270+
271+const (
272+ EventText EventKind = iota + 1
273+ EventCommand
274+ EventError
275+)
276+
277+// Event is emitted by Parser. Text and command payloads are owned by the
278+// event and remain valid after the next Feed call.
279+type Event struct {
280+ Kind EventKind
281+ Text []byte
282+ Command Command
283+ Err error
284+}
285+
286+// ParserConfig is a descriptive alias for Limits.
287+type ParserConfig = Limits
288+
289+// Parser incrementally recognizes ESC _ G APCs. It never interprets ordinary
290+// text or other escape sequences and can be fed arbitrary byte-sized chunks.
291+type Parser struct {
292+ limits Limits
293+ candidate []byte
294+ apc []byte
295+ inAPC bool
296+ discard bool
297+ escaped bool
298+ oversize bool
299+}
300+
301+// NewParser creates an incremental bounded parser.
302+func NewParser(config ...Limits) *Parser {
303+ var l Limits
304+ if len(config) != 0 {
305+ l = config[0]
306+ }
307+ return &Parser{limits: normalizeLimits(l)}
308+}
309+
310+// Limits reports the parser limits.
311+func (p *Parser) Limits() Limits {
312+ if p == nil {
313+ return Limits{}
314+ }
315+ return p.limits
316+}
317+
318+// MaxInt64 is exported for callers constructing checked protocol geometry.
319+const MaxInt64 = int64(math.MaxInt64)
320+
321+// MaxKittyID is the largest protocol image or placement identifier.
322+const MaxKittyID = uint64(^uint32(0))
323+
324+// Session owns protocol-to-scene mappings. It is safe for one owner to call
325+// Feed and query methods serially; the mutex also makes read-only mapping
326+// access safe while a caller publishes a snapshot.
327+type Session struct {
328+ mu sync.Mutex
329+ scene *graphics.Scene
330+ limits Limits
331+ parser *Parser
332+ images map[uint64]graphics.AssetID
333+ placements map[uint64]graphics.PlacementID
334+ children map[uint64]Child
335+ nextChild uint64
336+ upload *upload
337+}
338+
339+// Adapter is an alias for the stateful Kitty graphics session.
340+type Adapter = Session
341+
342+// Child describes a session-local image child and its graphics asset. Child
343+// IDs are never sent to the terminal; they make the protocol mapping explicit
344+// and stable for consumers.
345+type Child struct {
346+ ID uint64
347+ ImageID uint64
348+ AssetID graphics.AssetID
349+}
350+
351+// NewSession constructs an adapter over scene. A nil scene is allowed so the
352+// parser can still be used for validation, but commands which mutate a scene
353+// return ErrNoScene.
354+func NewSession(scene *graphics.Scene, config ...Limits) *Session {
355+ var l Limits
356+ if len(config) != 0 {
357+ l = config[0]
358+ }
359+ l = normalizeLimits(l)
360+ return &Session{
361+ scene: scene,
362+ limits: l,
363+ parser: NewParser(l),
364+ images: make(map[uint64]graphics.AssetID),
365+ placements: make(map[uint64]graphics.PlacementID),
366+ children: make(map[uint64]Child),
367+ nextChild: 1,
368+ }
369+}
370+
371+// NewAdapter is an adapter-named constructor alias.
372+func NewAdapter(scene *graphics.Scene, config ...Limits) *Session {
373+ return NewSession(scene, config...)
374+}
375+
376+// New is a concise constructor alias for NewSession.
377+func New(scene *graphics.Scene, config ...Limits) *Session { return NewSession(scene, config...) }
378+
379+// Scene returns the scene owned by the session.
380+func (s *Session) Scene() *graphics.Scene {
381+ if s == nil {
382+ return nil
383+ }
384+ return s.scene
385+}
386+
387+// Image returns the graphics asset mapped from a Kitty image ID.
388+func (s *Session) Image(imageID uint64) (graphics.AssetID, bool) {
389+ if s == nil {
390+ return graphics.AssetID{}, false
391+ }
392+ s.mu.Lock()
393+ defer s.mu.Unlock()
394+ id, ok := s.images[imageID]
395+ return id, ok
396+}
397+
398+// Asset is an alias for Image.
399+func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }
400+
401+// Placement returns the graphics placement mapped from a Kitty placement ID.
402+func (s *Session) Placement(placementID uint64) (graphics.PlacementID, bool) {
403+ if s == nil {
404+ return graphics.PlacementID{}, false
405+ }
406+ s.mu.Lock()
407+ defer s.mu.Unlock()
408+ id, ok := s.placements[placementID]
409+ return id, ok
410+}
411+
412+// PlacementID is an alias for Placement.
413+func (s *Session) PlacementID(placementID uint64) (graphics.PlacementID, bool) {
414+ return s.Placement(placementID)
415+}
416+
417+// Child returns the session child associated with an image ID.
418+func (s *Session) Child(imageID uint64) (Child, bool) {
419+ if s == nil {
420+ return Child{}, false
421+ }
422+ s.mu.Lock()
423+ defer s.mu.Unlock()
424+ for _, child := range s.children {
425+ if child.ImageID == imageID {
426+ return child, true
427+ }
428+ }
429+ return Child{}, false
430+}
431+
432+// ChildByID returns a session child by its adapter-local ID.
433+func (s *Session) ChildByID(childID uint64) (Child, bool) {
434+ if s == nil {
435+ return Child{}, false
436+ }
437+ s.mu.Lock()
438+ defer s.mu.Unlock()
439+ child, ok := s.children[childID]
440+ return child, ok
441+}
442+
443+// ChildID returns the adapter-local child ID associated with an image ID.
444+func (s *Session) ChildID(imageID uint64) (uint64, bool) {
445+ child, ok := s.Child(imageID)
446+ if !ok {
447+ return 0, false
448+ }
449+ return child.ID, true
450+}
451+
452+// Children returns a stable copy of all current session children in child-ID
453+// order.
454+func (s *Session) Children() []Child {
455+ if s == nil {
456+ return nil
457+ }
458+ s.mu.Lock()
459+ defer s.mu.Unlock()
460+ children := make([]Child, 0, len(s.children))
461+ for id := uint64(1); id < s.nextChild; id++ {
462+ if child, ok := s.children[id]; ok {
463+ children = append(children, child)
464+ }
465+ }
466+ return children
467+}
@@ -0,0 +1,467 @@
1+package kittygraphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "sync"
7+
8+ "github.com/bnema/vev-vt/graphics"
9+)
10+
11+var (
12+ ErrInvalidAPC = errors.New("invalid kitty graphics APC")
13+ ErrAPCTooLarge = errors.New("kitty graphics APC is too large")
14+ ErrAPCTruncated = errors.New("truncated kitty graphics APC")
15+ ErrInvalidCommand = errors.New("invalid kitty graphics command")
16+ ErrUnknownAction = errors.New("unknown kitty graphics action")
17+ ErrUnknownControl = errors.New("unknown kitty graphics control")
18+ ErrDuplicateControl = errors.New("duplicate kitty graphics control")
19+ ErrInvalidInteger = errors.New("invalid kitty graphics integer")
20+ ErrIntegerOverflow = errors.New("kitty graphics integer overflow")
21+ ErrInvalidBase64 = errors.New("invalid kitty graphics base64")
22+ ErrInvalidPNG = errors.New("invalid kitty graphics PNG")
23+ ErrPayloadTooLarge = errors.New("kitty graphics payload is too large")
24+ ErrTooManyChunks = errors.New("too many kitty graphics continuation chunks")
25+ ErrInterleavedUpload = errors.New("interleaved kitty graphics upload")
26+ ErrUnsupported = errors.New("unsupported kitty graphics command")
27+ ErrImageNotFound = errors.New("kitty graphics image not found")
28+ ErrPlacementNotFound = errors.New("kitty graphics placement not found")
29+ ErrNoScene = errors.New("kitty graphics scene is nil")
30+)
31+
32+const (
33+ // DefaultMaxAPCBytes bounds the complete framed APC, including its
34+ // controls and encoded payload.
35+ DefaultMaxAPCBytes = uint64(32 << 20)
36+ // DefaultMaxPayloadBytes bounds encoded bytes in one image, before base64
37+ // decoding. The graphics scene applies its own encoded-byte budget too.
38+ DefaultMaxPayloadBytes = uint64(24 << 20)
39+ DefaultMaxUploadBytes = uint64(16 << 20)
40+ DefaultMaxChunks = uint64(1024)
41+ DefaultMaxPixels = uint64(128 << 20)
42+ DefaultMaxDimension = uint64(1 << 20)
43+)
44+
45+// Limits bounds parser and adapter resource use. Zero values select bounded
46+// defaults. The limits are independent from the graphics scene limits: both
47+// layers enforce their budgets.
48+type Limits struct {
49+ MaxAPCBytes uint64
50+ MaxPayloadBytes uint64
51+ MaxUploadBytes uint64
52+ MaxChunks uint64
53+ MaxDecodedPixels uint64
54+ MaxDimension uint64
55+ MaxResponseBytes uint64
56+ MaxImages uint64
57+ MaxPlacements uint64
58+}
59+
60+// Config is an alias retained for callers that use constructor terminology.
61+type Config = Limits
62+
63+func normalizeLimits(l Limits) Limits {
64+ if l.MaxAPCBytes == 0 {
65+ l.MaxAPCBytes = DefaultMaxAPCBytes
66+ }
67+ if l.MaxPayloadBytes == 0 {
68+ l.MaxPayloadBytes = DefaultMaxPayloadBytes
69+ }
70+ if l.MaxUploadBytes == 0 {
71+ l.MaxUploadBytes = DefaultMaxUploadBytes
72+ }
73+ if l.MaxChunks == 0 {
74+ l.MaxChunks = DefaultMaxChunks
75+ }
76+ if l.MaxDecodedPixels == 0 {
77+ l.MaxDecodedPixels = DefaultMaxPixels
78+ }
79+ if l.MaxDimension == 0 {
80+ l.MaxDimension = DefaultMaxDimension
81+ }
82+ if l.MaxResponseBytes == 0 {
83+ l.MaxResponseBytes = 4096
84+ }
85+ if l.MaxImages == 0 {
86+ l.MaxImages = 1024
87+ }
88+ if l.MaxPlacements == 0 {
89+ l.MaxPlacements = 65536
90+ }
91+ return l
92+}
93+
94+// Action is the closed set of Kitty graphics actions understood by this
95+// adapter. The underlying byte values are the protocol values.
96+type Action byte
97+
98+const (
99+ ActionTransmit Action = 't'
100+ ActionTransmitDisplay Action = 'T'
101+ ActionPut Action = 'p'
102+ ActionQuery Action = 'q'
103+ ActionDelete Action = 'd'
104+ ActionFrame Action = 'f'
105+ ActionCompose Action = 'a'
106+
107+ // Descriptive aliases used by callers that prefer the protocol names.
108+ ActionTransmitAndDisplay = ActionTransmitDisplay
109+ ActionDisplay = ActionPut
110+)
111+
112+func (a Action) Valid() bool {
113+ switch a {
114+ case ActionTransmit, ActionTransmitDisplay, ActionPut, ActionQuery, ActionDelete, ActionFrame, ActionCompose:
115+ return true
116+ default:
117+ return false
118+ }
119+}
120+
121+func (a Action) String() string {
122+ if !a.Valid() {
123+ return "unknown"
124+ }
125+ return string([]byte{byte(a)})
126+}
127+
128+// Format is the closed set of Kitty image formats supported by this adapter.
129+type Format uint64
130+
131+const (
132+ FormatRGB Format = 24
133+ FormatRGBA Format = 32
134+ FormatPNG Format = 100
135+
136+ FormatRGB24 = FormatRGB
137+ FormatRGBA32 = FormatRGBA
138+)
139+
140+func (f Format) Valid() bool { return f == FormatRGB || f == FormatRGBA || f == FormatPNG }
141+
142+// Quiet controls protocol response emission. Kitty uses q=1 for successful
143+// operations only and q=2 for all responses.
144+type Quiet uint8
145+
146+const (
147+ QuietNever Quiet = 0
148+ QuietSuccess Quiet = 1
149+ QuietAll Quiet = 2
150+
151+ QuietNone = QuietNever
152+ QuietErrorsOnly = QuietSuccess
153+ QuietEverything = QuietAll
154+)
155+
156+// DeleteTarget is the closed set of targets accepted by a delete action.
157+type DeleteTarget byte
158+
159+const (
160+ DeleteImage DeleteTarget = 'i'
161+ DeleteImageNumber DeleteTarget = 'I'
162+ DeletePlacement DeleteTarget = 'p'
163+ DeleteAll DeleteTarget = 'a'
164+ DeleteAllImages DeleteTarget = 'A'
165+ DeleteAllPlacements DeleteTarget = 'P'
166+)
167+
168+// Transmission is the direct transmission mode. File, temporary-file and
169+// shared-memory transmissions are intentionally not accepted by this adapter.
170+type Transmission byte
171+
172+const TransmissionDirect Transmission = 'd'
173+
174+// ControlKey identifies a recognized Kitty control field.
175+type ControlKey byte
176+
177+const (
178+ ControlAction ControlKey = 'a'
179+ ControlCompression ControlKey = 'o'
180+ ControlFormat ControlKey = 'f'
181+ ControlImageID ControlKey = 'i'
182+ ControlImageNumber ControlKey = 'I'
183+ ControlMore ControlKey = 'm'
184+ ControlQuiet ControlKey = 'q'
185+ ControlWidth ControlKey = 's'
186+ ControlHeight ControlKey = 'v'
187+ ControlColumns ControlKey = 'c'
188+ ControlRows ControlKey = 'r'
189+ ControlX ControlKey = 'x'
190+ ControlY ControlKey = 'y'
191+ ControlSourceWidth ControlKey = 'w'
192+ ControlSourceHeight ControlKey = 'h'
193+ ControlLayer ControlKey = 'z'
194+ ControlPlacementID ControlKey = 'p'
195+ ControlDelete ControlKey = 'd'
196+ ControlTransmission ControlKey = 't'
197+ ControlCursor ControlKey = 'C'
198+ ControlSourceX ControlKey = 'X'
199+ ControlSourceY ControlKey = 'Y'
200+ ControlCellOffsetX ControlKey = 'H'
201+ ControlCellOffsetY ControlKey = 'V'
202+ ControlParent ControlKey = 'P'
203+)
204+
205+// Controls is the typed, closed representation of a Kitty control header.
206+// Has* fields preserve the distinction between an omitted field and zero.
207+type Controls struct {
208+ Action Action
209+ HasAction bool
210+ Compression uint64
211+ HasCompression bool
212+ Format Format
213+ HasFormat bool
214+ ImageID uint64
215+ HasImageID bool
216+ ImageNumber uint64
217+ HasImageNumber bool
218+ More uint64
219+ HasMore bool
220+ Quiet Quiet
221+ HasQuiet bool
222+ Width uint64
223+ HasWidth bool
224+ Height uint64
225+ HasHeight bool
226+ Columns uint64
227+ HasColumns bool
228+ Rows uint64
229+ HasRows bool
230+ X uint64
231+ HasX bool
232+ Y uint64
233+ HasY bool
234+ SourceWidth uint64
235+ HasSourceWidth bool
236+ SourceHeight uint64
237+ HasSourceHeight bool
238+ Layer int64
239+ HasLayer bool
240+ PlacementID uint64
241+ HasPlacementID bool
242+ Delete DeleteTarget
243+ HasDelete bool
244+ Transmission Transmission
245+ HasTransmission bool
246+ Cursor uint64
247+ HasCursor bool
248+ SourceX uint64
249+ HasSourceX bool
250+ SourceY uint64
251+ HasSourceY bool
252+ CellOffsetX int64
253+ HasCellOffsetX bool
254+ CellOffsetY int64
255+ HasCellOffsetY bool
256+ Parent uint64
257+ HasParent bool
258+}
259+
260+// Command is one complete APC graphics command. Payload is the encoded
261+// command payload and is copied from the parser input.
262+type Command struct {
263+ Controls Controls
264+ Payload []byte
265+}
266+
267+// EventKind identifies parser output. Text events contain bytes which are not
268+// Kitty graphics APCs; command events contain complete commands.
269+type EventKind uint8
270+
271+const (
272+ EventText EventKind = iota + 1
273+ EventCommand
274+ EventError
275+)
276+
277+// Event is emitted by Parser. Text and command payloads are owned by the
278+// event and remain valid after the next Feed call.
279+type Event struct {
280+ Kind EventKind
281+ Text []byte
282+ Command Command
283+ Err error
284+}
285+
286+// ParserConfig is a descriptive alias for Limits.
287+type ParserConfig = Limits
288+
289+// Parser incrementally recognizes ESC _ G APCs. It never interprets ordinary
290+// text or other escape sequences and can be fed arbitrary byte-sized chunks.
291+type Parser struct {
292+ limits Limits
293+ candidate []byte
294+ apc []byte
295+ inAPC bool
296+ discard bool
297+ escaped bool
298+ oversize bool
299+}
300+
301+// NewParser creates an incremental bounded parser.
302+func NewParser(config ...Limits) *Parser {
303+ var l Limits
304+ if len(config) != 0 {
305+ l = config[0]
306+ }
307+ return &Parser{limits: normalizeLimits(l)}
308+}
309+
310+// Limits reports the parser limits.
311+func (p *Parser) Limits() Limits {
312+ if p == nil {
313+ return Limits{}
314+ }
315+ return p.limits
316+}
317+
318+// MaxInt64 is exported for callers constructing checked protocol geometry.
319+const MaxInt64 = int64(math.MaxInt64)
320+
321+// MaxKittyID is the largest protocol image or placement identifier.
322+const MaxKittyID = uint64(^uint32(0))
323+
324+// Session owns protocol-to-scene mappings. It is safe for one owner to call
325+// Feed and query methods serially; the mutex also makes read-only mapping
326+// access safe while a caller publishes a snapshot.
327+type Session struct {
328+ mu sync.Mutex
329+ scene *graphics.Scene
330+ limits Limits
331+ parser *Parser
332+ images map[uint64]graphics.AssetID
333+ placements map[uint64]graphics.PlacementID
334+ children map[uint64]Child
335+ nextChild uint64
336+ upload *upload
337+}
338+
339+// Adapter is an alias for the stateful Kitty graphics session.
340+type Adapter = Session
341+
342+// Child describes a session-local image child and its graphics asset. Child
343+// IDs are never sent to the terminal; they make the protocol mapping explicit
344+// and stable for consumers.
345+type Child struct {
346+ ID uint64
347+ ImageID uint64
348+ AssetID graphics.AssetID
349+}
350+
351+// NewSession constructs an adapter over scene. A nil scene is allowed so the
352+// parser can still be used for validation, but commands which mutate a scene
353+// return ErrNoScene.
354+func NewSession(scene *graphics.Scene, config ...Limits) *Session {
355+ var l Limits
356+ if len(config) != 0 {
357+ l = config[0]
358+ }
359+ l = normalizeLimits(l)
360+ return &Session{
361+ scene: scene,
362+ limits: l,
363+ parser: NewParser(l),
364+ images: make(map[uint64]graphics.AssetID),
365+ placements: make(map[uint64]graphics.PlacementID),
366+ children: make(map[uint64]Child),
367+ nextChild: 1,
368+ }
369+}
370+
371+// NewAdapter is an adapter-named constructor alias.
372+func NewAdapter(scene *graphics.Scene, config ...Limits) *Session {
373+ return NewSession(scene, config...)
374+}
375+
376+// New is a concise constructor alias for NewSession.
377+func New(scene *graphics.Scene, config ...Limits) *Session { return NewSession(scene, config...) }
378+
379+// Scene returns the scene owned by the session.
380+func (s *Session) Scene() *graphics.Scene {
381+ if s == nil {
382+ return nil
383+ }
384+ return s.scene
385+}
386+
387+// Image returns the graphics asset mapped from a Kitty image ID.
388+func (s *Session) Image(imageID uint64) (graphics.AssetID, bool) {
389+ if s == nil {
390+ return graphics.AssetID{}, false
391+ }
392+ s.mu.Lock()
393+ defer s.mu.Unlock()
394+ id, ok := s.images[imageID]
395+ return id, ok
396+}
397+
398+// Asset is an alias for Image.
399+func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }
400+
401+// Placement returns the graphics placement mapped from a Kitty placement ID.
402+func (s *Session) Placement(placementID uint64) (graphics.PlacementID, bool) {
403+ if s == nil {
404+ return graphics.PlacementID{}, false
405+ }
406+ s.mu.Lock()
407+ defer s.mu.Unlock()
408+ id, ok := s.placements[placementID]
409+ return id, ok
410+}
411+
412+// PlacementID is an alias for Placement.
413+func (s *Session) PlacementID(placementID uint64) (graphics.PlacementID, bool) {
414+ return s.Placement(placementID)
415+}
416+
417+// Child returns the session child associated with an image ID.
418+func (s *Session) Child(imageID uint64) (Child, bool) {
419+ if s == nil {
420+ return Child{}, false
421+ }
422+ s.mu.Lock()
423+ defer s.mu.Unlock()
424+ for _, child := range s.children {
425+ if child.ImageID == imageID {
426+ return child, true
427+ }
428+ }
429+ return Child{}, false
430+}
431+
432+// ChildByID returns a session child by its adapter-local ID.
433+func (s *Session) ChildByID(childID uint64) (Child, bool) {
434+ if s == nil {
435+ return Child{}, false
436+ }
437+ s.mu.Lock()
438+ defer s.mu.Unlock()
439+ child, ok := s.children[childID]
440+ return child, ok
441+}
442+
443+// ChildID returns the adapter-local child ID associated with an image ID.
444+func (s *Session) ChildID(imageID uint64) (uint64, bool) {
445+ child, ok := s.Child(imageID)
446+ if !ok {
447+ return 0, false
448+ }
449+ return child.ID, true
450+}
451+
452+// Children returns a stable copy of all current session children in child-ID
453+// order.
454+func (s *Session) Children() []Child {
455+ if s == nil {
456+ return nil
457+ }
458+ s.mu.Lock()
459+ defer s.mu.Unlock()
460+ children := make([]Child, 0, len(s.children))
461+ for id := uint64(1); id < s.nextChild; id++ {
462+ if child, ok := s.children[id]; ok {
463+ children = append(children, child)
464+ }
465+ }
466+ return children
467+}
A protocol/kittygraphics/kittygraphics_test.go +209 -0
@@ -0,0 +1,209 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "encoding/base64"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "math/rand"
11+ "os"
12+ "testing"
13+
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+func apc(header, payload string) []byte {
18+ return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
19+}
20+
21+func pngBytes(t *testing.T, width, height int) []byte {
22+ t.Helper()
23+ img := image.NewRGBA(image.Rect(0, 0, width, height))
24+ img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
25+ var out bytes.Buffer
26+ if err := png.Encode(&out, img); err != nil {
27+ t.Fatal(err)
28+ }
29+ return out.Bytes()
30+}
31+
32+func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
33+ p := NewParser()
34+ var events []Event
35+ for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
36+ events = append(events, p.Feed([]byte{b})...)
37+ }
38+ var textBefore, textAfter []byte
39+ var command Event
40+ for _, event := range events {
41+ if event.Kind == EventText && command.Kind == 0 {
42+ textBefore = append(textBefore, event.Text...)
43+ }
44+ if event.Kind == EventCommand {
45+ command = event
46+ }
47+ if event.Kind == EventText && command.Kind != 0 {
48+ textAfter = append(textAfter, event.Text...)
49+ }
50+ }
51+ if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
52+ t.Fatalf("events = %#v", events)
53+ }
54+ if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
55+ t.Fatalf("command = %#v", command.Command)
56+ }
57+}
58+
59+func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
60+ data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ scene := graphics.NewScene(graphics.Limits{})
65+ session := NewSession(scene)
66+ // Feed split boundaries deliberately unrelated to APC boundaries. This
67+ // models the short writes observed from the current kitten/icat fixture.
68+ rng := rand.New(rand.NewSource(7))
69+ var result Result
70+ for len(data) != 0 {
71+ n := 1 + rng.Intn(4096)
72+ if n > len(data) {
73+ n = len(data)
74+ }
75+ part, err := session.Feed(data[:n])
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ result.Events = append(result.Events, part.Events...)
80+ result.Responses = append(result.Responses, part.Responses...)
81+ result.Mutations = append(result.Mutations, part.Mutations...)
82+ data = data[n:]
83+ }
84+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
85+ t.Fatalf("mutations = %#v", result.Mutations)
86+ }
87+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
88+ t.Fatalf("usage = %#v", got)
89+ }
90+ if len(result.Responses) != 0 {
91+ t.Fatalf("quiet fixture response = %q", result.Bytes())
92+ }
93+ if _, ok := session.Image(1); !ok {
94+ t.Fatal("fixture image mapping missing")
95+ }
96+}
97+
98+func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
99+ want := []byte{0, 1, 2, 253, 254, 255}
100+ standard := base64.StdEncoding.EncodeToString(want)
101+ raw := base64.RawStdEncoding.EncodeToString(want)
102+ for _, encoded := range []string{standard, raw} {
103+ got, err := DecodeBase64([]byte(encoded))
104+ if err != nil || !bytes.Equal(got, want) {
105+ t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
106+ }
107+ }
108+ if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
109+ t.Fatalf("invalid base64 error = %v", err)
110+ }
111+}
112+
113+func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
114+ scene := graphics.NewScene(graphics.Limits{})
115+ session := NewSession(scene)
116+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
117+ result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if len(result.Responses) != 0 || len(result.Mutations) != 1 {
122+ t.Fatalf("result = %#v", result)
123+ }
124+ asset, ok := session.Image(9)
125+ if !ok {
126+ t.Fatal("image mapping missing")
127+ }
128+ if _, err := session.Feed(apc("a=p,i=9,p=4,x=5,y=6,c=1,r=1", "")); err != nil {
129+ t.Fatal(err)
130+ }
131+ placement, ok := session.Placement(4)
132+ if !ok {
133+ t.Fatal("placement mapping missing")
134+ }
135+ view, ok := scene.Snapshot().Placement(placement)
136+ if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
137+ t.Fatalf("placement = %#v, ok=%v", view, ok)
138+ }
139+}
140+
141+func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
142+ if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
143+ t.Fatalf("duplicate control error = %v", err)
144+ }
145+ if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
146+ t.Fatalf("integer overflow error = %v", err)
147+ }
148+
149+ p := NewParser(Limits{MaxAPCBytes: 8})
150+ events := p.Feed(apc("a=q", "0123456789"))
151+ if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
152+ t.Fatalf("oversize events = %#v", events)
153+ }
154+ p = NewParser()
155+ if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
156+ t.Fatalf("truncated feed events = %#v", got)
157+ }
158+ got := p.Finish()
159+ if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
160+ t.Fatalf("truncated finish events = %#v", got)
161+ }
162+
163+ session := NewSession(graphics.NewScene(graphics.Limits{}))
164+ first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
165+ if _, err := session.Feed(first); err != nil {
166+ t.Fatal(err)
167+ }
168+ if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
169+ t.Fatalf("interleaved error = %v", err)
170+ }
171+ if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
172+ t.Fatal(err)
173+ }
174+}
175+
176+func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
177+ good := pngBytes(t, 1, 1)
178+ scene := graphics.NewScene(graphics.Limits{})
179+ session := NewSession(scene)
180+ if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
181+ t.Fatalf("malformed PNG error = %v", err)
182+ }
183+ if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
184+ t.Fatalf("malformed PNG mutated scene: %#v", got)
185+ }
186+}
187+
188+func FuzzParserNeverPanics(f *testing.F) {
189+ f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
190+ f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
191+ f.Fuzz(func(t *testing.T, data []byte) {
192+ p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
193+ _ = p.Feed(data)
194+ _ = p.Finish()
195+ })
196+}
197+
198+func FuzzSessionNeverPanics(f *testing.F) {
199+ f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
200+ f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
201+ f.Fuzz(func(t *testing.T, data []byte) {
202+ s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
203+ MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
204+ MaxDimension: 128, MaxDecodedPixels: 4096,
205+ })
206+ _, _ = s.Feed(data)
207+ _, _ = s.Finish()
208+ })
209+}
@@ -0,0 +1,209 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "encoding/base64"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "math/rand"
11+ "os"
12+ "testing"
13+
14+ "github.com/bnema/vev-vt/graphics"
15+)
16+
17+func apc(header, payload string) []byte {
18+ return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
19+}
20+
21+func pngBytes(t *testing.T, width, height int) []byte {
22+ t.Helper()
23+ img := image.NewRGBA(image.Rect(0, 0, width, height))
24+ img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
25+ var out bytes.Buffer
26+ if err := png.Encode(&out, img); err != nil {
27+ t.Fatal(err)
28+ }
29+ return out.Bytes()
30+}
31+
32+func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
33+ p := NewParser()
34+ var events []Event
35+ for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
36+ events = append(events, p.Feed([]byte{b})...)
37+ }
38+ var textBefore, textAfter []byte
39+ var command Event
40+ for _, event := range events {
41+ if event.Kind == EventText && command.Kind == 0 {
42+ textBefore = append(textBefore, event.Text...)
43+ }
44+ if event.Kind == EventCommand {
45+ command = event
46+ }
47+ if event.Kind == EventText && command.Kind != 0 {
48+ textAfter = append(textAfter, event.Text...)
49+ }
50+ }
51+ if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
52+ t.Fatalf("events = %#v", events)
53+ }
54+ if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
55+ t.Fatalf("command = %#v", command.Command)
56+ }
57+}
58+
59+func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
60+ data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ scene := graphics.NewScene(graphics.Limits{})
65+ session := NewSession(scene)
66+ // Feed split boundaries deliberately unrelated to APC boundaries. This
67+ // models the short writes observed from the current kitten/icat fixture.
68+ rng := rand.New(rand.NewSource(7))
69+ var result Result
70+ for len(data) != 0 {
71+ n := 1 + rng.Intn(4096)
72+ if n > len(data) {
73+ n = len(data)
74+ }
75+ part, err := session.Feed(data[:n])
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ result.Events = append(result.Events, part.Events...)
80+ result.Responses = append(result.Responses, part.Responses...)
81+ result.Mutations = append(result.Mutations, part.Mutations...)
82+ data = data[n:]
83+ }
84+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
85+ t.Fatalf("mutations = %#v", result.Mutations)
86+ }
87+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
88+ t.Fatalf("usage = %#v", got)
89+ }
90+ if len(result.Responses) != 0 {
91+ t.Fatalf("quiet fixture response = %q", result.Bytes())
92+ }
93+ if _, ok := session.Image(1); !ok {
94+ t.Fatal("fixture image mapping missing")
95+ }
96+}
97+
98+func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
99+ want := []byte{0, 1, 2, 253, 254, 255}
100+ standard := base64.StdEncoding.EncodeToString(want)
101+ raw := base64.RawStdEncoding.EncodeToString(want)
102+ for _, encoded := range []string{standard, raw} {
103+ got, err := DecodeBase64([]byte(encoded))
104+ if err != nil || !bytes.Equal(got, want) {
105+ t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
106+ }
107+ }
108+ if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
109+ t.Fatalf("invalid base64 error = %v", err)
110+ }
111+}
112+
113+func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
114+ scene := graphics.NewScene(graphics.Limits{})
115+ session := NewSession(scene)
116+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
117+ result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ if len(result.Responses) != 0 || len(result.Mutations) != 1 {
122+ t.Fatalf("result = %#v", result)
123+ }
124+ asset, ok := session.Image(9)
125+ if !ok {
126+ t.Fatal("image mapping missing")
127+ }
128+ if _, err := session.Feed(apc("a=p,i=9,p=4,x=5,y=6,c=1,r=1", "")); err != nil {
129+ t.Fatal(err)
130+ }
131+ placement, ok := session.Placement(4)
132+ if !ok {
133+ t.Fatal("placement mapping missing")
134+ }
135+ view, ok := scene.Snapshot().Placement(placement)
136+ if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
137+ t.Fatalf("placement = %#v, ok=%v", view, ok)
138+ }
139+}
140+
141+func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
142+ if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
143+ t.Fatalf("duplicate control error = %v", err)
144+ }
145+ if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
146+ t.Fatalf("integer overflow error = %v", err)
147+ }
148+
149+ p := NewParser(Limits{MaxAPCBytes: 8})
150+ events := p.Feed(apc("a=q", "0123456789"))
151+ if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
152+ t.Fatalf("oversize events = %#v", events)
153+ }
154+ p = NewParser()
155+ if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
156+ t.Fatalf("truncated feed events = %#v", got)
157+ }
158+ got := p.Finish()
159+ if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
160+ t.Fatalf("truncated finish events = %#v", got)
161+ }
162+
163+ session := NewSession(graphics.NewScene(graphics.Limits{}))
164+ first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
165+ if _, err := session.Feed(first); err != nil {
166+ t.Fatal(err)
167+ }
168+ if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
169+ t.Fatalf("interleaved error = %v", err)
170+ }
171+ if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
172+ t.Fatal(err)
173+ }
174+}
175+
176+func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
177+ good := pngBytes(t, 1, 1)
178+ scene := graphics.NewScene(graphics.Limits{})
179+ session := NewSession(scene)
180+ if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
181+ t.Fatalf("malformed PNG error = %v", err)
182+ }
183+ if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
184+ t.Fatalf("malformed PNG mutated scene: %#v", got)
185+ }
186+}
187+
188+func FuzzParserNeverPanics(f *testing.F) {
189+ f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
190+ f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
191+ f.Fuzz(func(t *testing.T, data []byte) {
192+ p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
193+ _ = p.Feed(data)
194+ _ = p.Finish()
195+ })
196+}
197+
198+func FuzzSessionNeverPanics(f *testing.F) {
199+ f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
200+ f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
201+ f.Fuzz(func(t *testing.T, data []byte) {
202+ s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
203+ MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
204+ MaxDimension: 128, MaxDecodedPixels: 4096,
205+ })
206+ _, _ = s.Feed(data)
207+ _, _ = s.Finish()
208+ })
209+}
A protocol/kittygraphics/parser.go +204 -0
@@ -0,0 +1,204 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "fmt"
6+)
7+
8+var apcPrefix = [...]byte{0x1b, '_', 'G'}
9+
10+// Feed consumes data and returns text and complete graphics events. The
11+// parser retains only an incomplete APC or a possible split prefix between
12+// calls. A malformed APC is reported as an EventError and discarded through
13+// its string terminator, allowing following text and APCs to be processed.
14+func (p *Parser) Feed(data []byte) []Event {
15+ if p == nil {
16+ if len(data) == 0 {
17+ return nil
18+ }
19+ return []Event{{Kind: EventText, Text: append([]byte(nil), data...)}}
20+ }
21+ var events []Event
22+ text := make([]byte, 0, len(data))
23+ flushText := func() {
24+ if len(text) == 0 {
25+ return
26+ }
27+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), text...)})
28+ text = text[:0]
29+ }
30+
31+ for _, b := range data {
32+ if !p.inAPC {
33+ p.candidate = append(p.candidate, b)
34+ for len(p.candidate) != 0 {
35+ if len(p.candidate) <= len(apcPrefix) && bytes.Equal(p.candidate, apcPrefix[:len(p.candidate)]) {
36+ break
37+ }
38+ text = append(text, p.candidate[0])
39+ p.candidate = p.candidate[1:]
40+ }
41+ if len(p.candidate) == len(apcPrefix) {
42+ flushText()
43+ p.candidate = p.candidate[:0]
44+ p.inAPC = true
45+ p.discard = false
46+ p.escaped = false
47+ p.oversize = false
48+ p.apc = p.apc[:0]
49+ }
50+ continue
51+ }
52+
53+ if p.escaped {
54+ if b == '\\' {
55+ if p.discard || p.oversize {
56+ events = append(events, p.errorEvent())
57+ } else {
58+ events = append(events, p.commandEvent())
59+ }
60+ p.resetAPC()
61+ continue
62+ }
63+ // An ESC that is not followed by backslash is payload. Keep the
64+ // byte and continue looking for a terminator. This is necessary
65+ // for raw payloads and is harmless for base64 payloads.
66+ p.appendAPC(0x1b)
67+ p.escaped = false
68+ }
69+ if b == 0x9c { // C1 ST is also a valid APC terminator.
70+ if p.discard || p.oversize {
71+ events = append(events, p.errorEvent())
72+ } else {
73+ events = append(events, p.commandEvent())
74+ }
75+ p.resetAPC()
76+ continue
77+ }
78+ if b == 0x1b {
79+ p.escaped = true
80+ continue
81+ }
82+ p.appendAPC(b)
83+ }
84+ flushText()
85+ return events
86+}
87+
88+// Parse is an alias for Feed.
89+func (p *Parser) Parse(data []byte) []Event { return p.Feed(data) }
90+
91+// FeedWithErrors is a conventional error-returning facade over Feed. Parser
92+// errors are also retained in the returned Event values.
93+func (p *Parser) FeedWithErrors(data []byte) ([]Event, error) {
94+ events := p.Feed(data)
95+ for _, event := range events {
96+ if event.Kind == EventError && event.Err != nil {
97+ return events, event.Err
98+ }
99+ }
100+ return events, nil
101+}
102+
103+// Finish reports an unterminated APC and resets the parser. Text which is a
104+// partial ordinary escape sequence is returned as text. Finish is useful at a
105+// stream boundary; a caller that will feed more data must not call it.
106+func (p *Parser) Finish() []Event {
107+ if p == nil {
108+ return nil
109+ }
110+ var events []Event
111+ if p.inAPC {
112+ err := ErrAPCTruncated
113+ if p.oversize {
114+ err = ErrAPCTooLarge
115+ }
116+ events = append(events, Event{Kind: EventError, Err: err})
117+ p.resetAPC()
118+ }
119+ if len(p.candidate) != 0 {
120+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), p.candidate...)})
121+ p.candidate = p.candidate[:0]
122+ }
123+ return events
124+}
125+
126+// Flush is an alias for Finish.
127+func (p *Parser) Flush() []Event { return p.Finish() }
128+
129+func (p *Parser) appendAPC(b byte) {
130+ if p.discard || p.oversize {
131+ return
132+ }
133+ if uint64(len(p.apc))+1 > p.limits.MaxAPCBytes {
134+ p.oversize = true
135+ return
136+ }
137+ p.apc = append(p.apc, b)
138+}
139+
140+func (p *Parser) resetAPC() {
141+ p.inAPC = false
142+ p.discard = false
143+ p.escaped = false
144+ p.oversize = false
145+ p.apc = p.apc[:0]
146+}
147+
148+func (p *Parser) errorEvent() Event {
149+ if p.oversize {
150+ return Event{Kind: EventError, Err: ErrAPCTooLarge}
151+ }
152+ return Event{Kind: EventError, Err: ErrInvalidAPC}
153+}
154+
155+func (p *Parser) commandEvent() Event {
156+ command, err := ParseCommand(p.apc, p.limits)
157+ if err != nil {
158+ return Event{Kind: EventError, Err: err}
159+ }
160+ return Event{Kind: EventCommand, Command: command}
161+}
162+
163+// ParseCommand parses the bytes between APC's G and its ST terminator. It is
164+// exported separately so adapters can validate commands without maintaining a
165+// stream parser.
166+func ParseCommand(body []byte, config ...Limits) (Command, error) {
167+ var l Limits
168+ if len(config) != 0 {
169+ l = config[0]
170+ }
171+ l = normalizeLimits(l)
172+ if uint64(len(body)) > l.MaxAPCBytes {
173+ return Command{}, ErrAPCTooLarge
174+ }
175+ sep := bytes.IndexByte(body, ';')
176+ if sep < 0 {
177+ return Command{}, fmt.Errorf("%w: missing payload separator", ErrInvalidCommand)
178+ }
179+ controls, err := parseControls(body[:sep])
180+ if err != nil {
181+ return Command{}, err
182+ }
183+ payload := body[sep+1:]
184+ if uint64(len(payload)) > l.MaxPayloadBytes {
185+ return Command{}, ErrPayloadTooLarge
186+ }
187+ return Command{Controls: controls, Payload: append([]byte(nil), payload...)}, nil
188+}
189+
190+// ParseAPC parses a complete ESC _ G ... ST sequence.
191+func ParseAPC(apc []byte, config ...Limits) (Command, error) {
192+ if len(apc) < 5 || apc[0] != 0x1b || apc[1] != '_' || apc[2] != 'G' {
193+ return Command{}, ErrInvalidAPC
194+ }
195+ body := apc[3:]
196+ if body[len(body)-1] == 0x9c {
197+ body = body[:len(body)-1]
198+ } else if len(body) >= 2 && body[len(body)-2] == 0x1b && body[len(body)-1] == '\\' {
199+ body = body[:len(body)-2]
200+ } else {
201+ return Command{}, ErrAPCTruncated
202+ }
203+ return ParseCommand(body, config...)
204+}
@@ -0,0 +1,204 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "fmt"
6+)
7+
8+var apcPrefix = [...]byte{0x1b, '_', 'G'}
9+
10+// Feed consumes data and returns text and complete graphics events. The
11+// parser retains only an incomplete APC or a possible split prefix between
12+// calls. A malformed APC is reported as an EventError and discarded through
13+// its string terminator, allowing following text and APCs to be processed.
14+func (p *Parser) Feed(data []byte) []Event {
15+ if p == nil {
16+ if len(data) == 0 {
17+ return nil
18+ }
19+ return []Event{{Kind: EventText, Text: append([]byte(nil), data...)}}
20+ }
21+ var events []Event
22+ text := make([]byte, 0, len(data))
23+ flushText := func() {
24+ if len(text) == 0 {
25+ return
26+ }
27+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), text...)})
28+ text = text[:0]
29+ }
30+
31+ for _, b := range data {
32+ if !p.inAPC {
33+ p.candidate = append(p.candidate, b)
34+ for len(p.candidate) != 0 {
35+ if len(p.candidate) <= len(apcPrefix) && bytes.Equal(p.candidate, apcPrefix[:len(p.candidate)]) {
36+ break
37+ }
38+ text = append(text, p.candidate[0])
39+ p.candidate = p.candidate[1:]
40+ }
41+ if len(p.candidate) == len(apcPrefix) {
42+ flushText()
43+ p.candidate = p.candidate[:0]
44+ p.inAPC = true
45+ p.discard = false
46+ p.escaped = false
47+ p.oversize = false
48+ p.apc = p.apc[:0]
49+ }
50+ continue
51+ }
52+
53+ if p.escaped {
54+ if b == '\\' {
55+ if p.discard || p.oversize {
56+ events = append(events, p.errorEvent())
57+ } else {
58+ events = append(events, p.commandEvent())
59+ }
60+ p.resetAPC()
61+ continue
62+ }
63+ // An ESC that is not followed by backslash is payload. Keep the
64+ // byte and continue looking for a terminator. This is necessary
65+ // for raw payloads and is harmless for base64 payloads.
66+ p.appendAPC(0x1b)
67+ p.escaped = false
68+ }
69+ if b == 0x9c { // C1 ST is also a valid APC terminator.
70+ if p.discard || p.oversize {
71+ events = append(events, p.errorEvent())
72+ } else {
73+ events = append(events, p.commandEvent())
74+ }
75+ p.resetAPC()
76+ continue
77+ }
78+ if b == 0x1b {
79+ p.escaped = true
80+ continue
81+ }
82+ p.appendAPC(b)
83+ }
84+ flushText()
85+ return events
86+}
87+
88+// Parse is an alias for Feed.
89+func (p *Parser) Parse(data []byte) []Event { return p.Feed(data) }
90+
91+// FeedWithErrors is a conventional error-returning facade over Feed. Parser
92+// errors are also retained in the returned Event values.
93+func (p *Parser) FeedWithErrors(data []byte) ([]Event, error) {
94+ events := p.Feed(data)
95+ for _, event := range events {
96+ if event.Kind == EventError && event.Err != nil {
97+ return events, event.Err
98+ }
99+ }
100+ return events, nil
101+}
102+
103+// Finish reports an unterminated APC and resets the parser. Text which is a
104+// partial ordinary escape sequence is returned as text. Finish is useful at a
105+// stream boundary; a caller that will feed more data must not call it.
106+func (p *Parser) Finish() []Event {
107+ if p == nil {
108+ return nil
109+ }
110+ var events []Event
111+ if p.inAPC {
112+ err := ErrAPCTruncated
113+ if p.oversize {
114+ err = ErrAPCTooLarge
115+ }
116+ events = append(events, Event{Kind: EventError, Err: err})
117+ p.resetAPC()
118+ }
119+ if len(p.candidate) != 0 {
120+ events = append(events, Event{Kind: EventText, Text: append([]byte(nil), p.candidate...)})
121+ p.candidate = p.candidate[:0]
122+ }
123+ return events
124+}
125+
126+// Flush is an alias for Finish.
127+func (p *Parser) Flush() []Event { return p.Finish() }
128+
129+func (p *Parser) appendAPC(b byte) {
130+ if p.discard || p.oversize {
131+ return
132+ }
133+ if uint64(len(p.apc))+1 > p.limits.MaxAPCBytes {
134+ p.oversize = true
135+ return
136+ }
137+ p.apc = append(p.apc, b)
138+}
139+
140+func (p *Parser) resetAPC() {
141+ p.inAPC = false
142+ p.discard = false
143+ p.escaped = false
144+ p.oversize = false
145+ p.apc = p.apc[:0]
146+}
147+
148+func (p *Parser) errorEvent() Event {
149+ if p.oversize {
150+ return Event{Kind: EventError, Err: ErrAPCTooLarge}
151+ }
152+ return Event{Kind: EventError, Err: ErrInvalidAPC}
153+}
154+
155+func (p *Parser) commandEvent() Event {
156+ command, err := ParseCommand(p.apc, p.limits)
157+ if err != nil {
158+ return Event{Kind: EventError, Err: err}
159+ }
160+ return Event{Kind: EventCommand, Command: command}
161+}
162+
163+// ParseCommand parses the bytes between APC's G and its ST terminator. It is
164+// exported separately so adapters can validate commands without maintaining a
165+// stream parser.
166+func ParseCommand(body []byte, config ...Limits) (Command, error) {
167+ var l Limits
168+ if len(config) != 0 {
169+ l = config[0]
170+ }
171+ l = normalizeLimits(l)
172+ if uint64(len(body)) > l.MaxAPCBytes {
173+ return Command{}, ErrAPCTooLarge
174+ }
175+ sep := bytes.IndexByte(body, ';')
176+ if sep < 0 {
177+ return Command{}, fmt.Errorf("%w: missing payload separator", ErrInvalidCommand)
178+ }
179+ controls, err := parseControls(body[:sep])
180+ if err != nil {
181+ return Command{}, err
182+ }
183+ payload := body[sep+1:]
184+ if uint64(len(payload)) > l.MaxPayloadBytes {
185+ return Command{}, ErrPayloadTooLarge
186+ }
187+ return Command{Controls: controls, Payload: append([]byte(nil), payload...)}, nil
188+}
189+
190+// ParseAPC parses a complete ESC _ G ... ST sequence.
191+func ParseAPC(apc []byte, config ...Limits) (Command, error) {
192+ if len(apc) < 5 || apc[0] != 0x1b || apc[1] != '_' || apc[2] != 'G' {
193+ return Command{}, ErrInvalidAPC
194+ }
195+ body := apc[3:]
196+ if body[len(body)-1] == 0x9c {
197+ body = body[:len(body)-1]
198+ } else if len(body) >= 2 && body[len(body)-2] == 0x1b && body[len(body)-1] == '\\' {
199+ body = body[:len(body)-2]
200+ } else {
201+ return Command{}, ErrAPCTruncated
202+ }
203+ return ParseCommand(body, config...)
204+}
A protocol/kittygraphics/session.go +753 -0
@@ -0,0 +1,753 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "image/png"
7+ "math"
8+ "strconv"
9+
10+ "github.com/bnema/vev-vt/graphics"
11+)
12+
13+type upload struct {
14+ controls Controls
15+ imageID uint64
16+ payload []byte
17+ chunks uint64
18+}
19+
20+// MutationKind describes a scene mutation performed by a session.
21+type MutationKind uint8
22+
23+const (
24+ MutationUpload MutationKind = iota + 1
25+ MutationPlacement
26+ MutationDeleteImage
27+ MutationDeletePlacement
28+ MutationClear
29+)
30+
31+// Mutation records the stable protocol and opaque scene IDs involved in one
32+// accepted command.
33+type Mutation struct {
34+ Kind MutationKind
35+ ImageID uint64
36+ ChildID uint64
37+ AssetID graphics.AssetID
38+ PlacementID uint64
39+ ScenePlacementID graphics.PlacementID
40+}
41+
42+// Result contains all output from one or more commands in a Feed call.
43+type Result struct {
44+ Events []Event
45+ Responses [][]byte
46+ Mutations []Mutation
47+}
48+
49+// Bytes returns response bytes in stable command order. The returned slice is
50+// newly allocated and may be modified by the caller.
51+func (r Result) Bytes() []byte {
52+ var n int
53+ for _, response := range r.Responses {
54+ n += len(response)
55+ }
56+ out := make([]byte, 0, n)
57+ for _, response := range r.Responses {
58+ out = append(out, response...)
59+ }
60+ return out
61+}
62+
63+// Response is the wire representation of a stable Kitty response.
64+type Response struct {
65+ ImageID uint64
66+ OK bool
67+ Code string
68+ Bytes []byte
69+}
70+
71+const responsePrefix = "\x1b_G"
72+const responseSuffix = "\x1b\\"
73+
74+// MakeResponse constructs the canonical Kitty response used by Session.
75+func MakeResponse(imageID uint64, code string) []byte {
76+ if code == "" {
77+ code = "OK"
78+ }
79+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ";" + code + responseSuffix)
80+}
81+
82+// Feed parses and applies arbitrary bytes. Ordinary text is returned as
83+// EventText; it is never interpreted, discarded, or passed through graphics
84+// scene mutation. A malformed command is reported after all complete events
85+// in the same input have been handled.
86+func (s *Session) Feed(data []byte) (Result, error) {
87+ if s == nil {
88+ return Result{}, ErrNoScene
89+ }
90+ s.mu.Lock()
91+ defer s.mu.Unlock()
92+ var result Result
93+ for _, event := range s.parser.Feed(data) {
94+ result.Events = append(result.Events, event)
95+ if event.Kind != EventCommand {
96+ continue
97+ }
98+ responses, mutation, err := s.apply(event.Command)
99+ if len(responses) != 0 {
100+ result.Responses = append(result.Responses, responses...)
101+ }
102+ if mutation != nil {
103+ result.Mutations = append(result.Mutations, *mutation)
104+ }
105+ if err != nil {
106+ // Keep the error attached to the event as well as returning it
107+ // below. This makes mixed text/graphics feeds inspectable without
108+ // requiring callers to reconstruct command order.
109+ result.Events[len(result.Events)-1].Kind = EventError
110+ result.Events[len(result.Events)-1].Err = err
111+ }
112+ }
113+ var first error
114+ for _, event := range result.Events {
115+ if event.Kind == EventError && event.Err != nil {
116+ first = event.Err
117+ break
118+ }
119+ }
120+ return result, first
121+}
122+
123+// Write is an adapter-oriented alias for Feed.
124+func (s *Session) Write(data []byte) (Result, error) { return s.Feed(data) }
125+
126+// Handle is an adapter-oriented alias for Feed.
127+func (s *Session) Handle(data []byte) (Result, error) { return s.Feed(data) }
128+
129+// Finish flushes an incomplete parser sequence. It never mutates the scene.
130+func (s *Session) Finish() (Result, error) {
131+ if s == nil {
132+ return Result{}, ErrNoScene
133+ }
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ result := Result{}
137+ for _, event := range s.parser.Finish() {
138+ result.Events = append(result.Events, event)
139+ }
140+ if len(result.Events) != 0 {
141+ return result, result.Events[0].Err
142+ }
143+ return result, nil
144+}
145+
146+// Process applies one already parsed command. It is useful for callers that
147+// have their own stream framing but want this package's strict adapter.
148+func (s *Session) Process(command Command) (Result, error) {
149+ if s == nil {
150+ return Result{}, ErrNoScene
151+ }
152+ s.mu.Lock()
153+ defer s.mu.Unlock()
154+ responses, mutation, err := s.apply(command)
155+ result := Result{Responses: responses}
156+ if mutation != nil {
157+ result.Mutations = []Mutation{*mutation}
158+ }
159+ return result, err
160+}
161+
162+func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
163+ c := command.Controls
164+ action := c.Action
165+ if !c.HasAction {
166+ action = ActionTransmit
167+ }
168+ if c.HasCompression && c.Compression != 0 {
169+ return s.failure(c, controlsImageID(c), ErrUnsupported)
170+ }
171+ if c.HasTransmission && c.Transmission != TransmissionDirect {
172+ return s.failure(c, controlsImageID(c), ErrUnsupported)
173+ }
174+ if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
175+ return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
176+ }
177+ switch action {
178+ case ActionTransmit, ActionTransmitDisplay:
179+ return s.transmit(command, action == ActionTransmitDisplay)
180+ case ActionPut:
181+ if len(command.Payload) != 0 {
182+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
183+ }
184+ return s.put(c)
185+ case ActionQuery:
186+ if len(command.Payload) != 0 {
187+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
188+ }
189+ return s.query(c)
190+ case ActionDelete:
191+ if len(command.Payload) != 0 {
192+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
193+ }
194+ return s.delete(c)
195+ case ActionFrame, ActionCompose:
196+ return s.failure(c, controlsImageID(c), ErrUnsupported)
197+ default:
198+ return s.failure(c, controlsImageID(c), ErrUnknownAction)
199+ }
200+}
201+
202+func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
203+ c := command.Controls
204+ imageID := controlsImageID(c)
205+ if s.upload != nil {
206+ if !continuationControlsAllowed(c, s.upload.controls) {
207+ return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
208+ }
209+ hasImageSelector := c.HasImageID || c.HasImageNumber
210+ if hasImageSelector && imageID != 0 && imageID != s.upload.imageID {
211+ return s.failure(c, imageID, ErrInterleavedUpload)
212+ }
213+ if hasImageSelector && imageID == 0 {
214+ return s.failure(c, s.upload.imageID, ErrInterleavedUpload)
215+ }
216+ imageID = s.upload.imageID
217+ if s.upload.chunks >= s.limits.MaxChunks {
218+ return s.abortUpload(c, imageID, ErrTooManyChunks)
219+ }
220+ if uint64(len(s.upload.payload))+uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
221+ return s.abortUpload(c, imageID, ErrPayloadTooLarge)
222+ }
223+ s.upload.payload = append(s.upload.payload, command.Payload...)
224+ s.upload.chunks++
225+ s.upload.controls = mergeControls(s.upload.controls, c)
226+ if c.HasMore && c.More == 1 {
227+ return nil, nil, nil
228+ }
229+ complete := *s.upload
230+ s.upload = nil
231+ complete.controls = mergeControls(complete.controls, c)
232+ return s.commitUpload(complete, display)
233+ }
234+
235+ if imageID == 0 {
236+ imageID = s.nextImageID()
237+ }
238+ if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
239+ return s.failure(c, imageID, ErrPayloadTooLarge)
240+ }
241+ if c.HasMore && c.More == 1 {
242+ s.upload = &upload{
243+ controls: c,
244+ imageID: imageID,
245+ payload: append([]byte(nil), command.Payload...),
246+ chunks: 1,
247+ }
248+ return nil, nil, nil
249+ }
250+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1}, display)
251+}
252+
253+func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
254+ if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
255+ return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
256+ }
257+ decoded, err := DecodeBase64(value.payload)
258+ if err != nil {
259+ return s.failure(value.controls, value.imageID, err)
260+ }
261+ format := value.controls.Format
262+ if !value.controls.HasFormat {
263+ format = FormatRGBA
264+ }
265+ width, height, err := imageGeometry(decoded, format, value.controls, s.limits)
266+ if err != nil {
267+ return s.failure(value.controls, value.imageID, err)
268+ }
269+ if s.scene == nil {
270+ return s.failure(value.controls, value.imageID, ErrNoScene)
271+ }
272+ if old, ok := s.images[value.imageID]; ok {
273+ // Remove adapter references while the old scene snapshot still
274+ // contains its placements, then remove the scene asset atomically.
275+ s.removeMappingsForAsset(old)
276+ if err := s.scene.RemoveAssetCascade(old); err != nil {
277+ return s.failure(value.controls, value.imageID, err)
278+ }
279+ }
280+ if uint64(len(s.images)) >= s.limits.MaxImages {
281+ return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
282+ }
283+ assetID, err := s.scene.AddAsset(graphics.AssetBlob{
284+ Encoded: decoded,
285+ Width: width,
286+ Height: height,
287+ })
288+ if err != nil {
289+ return s.failure(value.controls, value.imageID, err)
290+ }
291+ s.images[value.imageID] = assetID
292+ childID := s.nextChild
293+ s.nextChild++
294+ s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
295+ mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
296+ if display {
297+ responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID)
298+ if placementMutation != nil {
299+ mutation.Kind = MutationPlacement
300+ mutation.PlacementID = placementMutation.PlacementID
301+ mutation.ScenePlacementID = placementMutation.ScenePlacementID
302+ }
303+ if placeErr != nil {
304+ // T (transmit and display) is one protocol operation. Do not
305+ // leave an uploaded asset behind when its placement is rejected.
306+ _ = s.scene.RemoveAssetCascade(assetID)
307+ delete(s.images, value.imageID)
308+ delete(s.children, childID)
309+ return responses, nil, placeErr
310+ }
311+ return s.success(value.controls, value.imageID, responses...), mutation, nil
312+ }
313+ return s.success(value.controls, value.imageID), mutation, nil
314+}
315+
316+func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64, int64, error) {
317+ if format == FormatPNG {
318+ config, err := png.DecodeConfig(bytes.NewReader(data))
319+ if err != nil {
320+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
321+ }
322+ if config.Width <= 0 || config.Height <= 0 {
323+ return 0, 0, ErrInvalidPNG
324+ }
325+ if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
326+ return 0, 0, ErrPayloadTooLarge
327+ }
328+ if uint64(config.Width) > math.MaxInt64/uint64(config.Height) || uint64(config.Width)*uint64(config.Height) > limits.MaxDecodedPixels {
329+ return 0, 0, ErrPayloadTooLarge
330+ }
331+ if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
332+ return 0, 0, fmt.Errorf("%w: PNG dimensions do not match controls", ErrInvalidPNG)
333+ }
334+ // Decode, rather than only DecodeConfig, so malformed/truncated IDAT
335+ // data and CRC failures are rejected by the standard library.
336+ if _, err := png.Decode(bytes.NewReader(data)); err != nil {
337+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
338+ }
339+ return int64(config.Width), int64(config.Height), nil
340+ }
341+ if !format.Valid() {
342+ return 0, 0, ErrUnsupported
343+ }
344+ if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
345+ return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
346+ }
347+ if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension || c.Width > math.MaxInt64/c.Height || c.Width*c.Height > limits.MaxDecodedPixels {
348+ return 0, 0, ErrPayloadTooLarge
349+ }
350+ channels := uint64(3)
351+ if format == FormatRGBA {
352+ channels = 4
353+ }
354+ if c.Width > math.MaxUint64/(c.Height*channels) {
355+ return 0, 0, ErrPayloadTooLarge
356+ }
357+ expected := c.Width * c.Height * channels
358+ if uint64(len(data)) != expected {
359+ return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
360+ }
361+ return int64(c.Width), int64(c.Height), nil
362+}
363+
364+func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
365+ imageID := controlsImageID(c)
366+ if imageID == 0 {
367+ return s.failure(c, imageID, ErrImageNotFound)
368+ }
369+ assetID, ok := s.images[imageID]
370+ if !ok {
371+ return s.failure(c, imageID, ErrImageNotFound)
372+ }
373+ responses, mutation, err := s.place(c, imageID, assetID)
374+ if err != nil {
375+ return responses, mutation, err
376+ }
377+ return s.success(c, imageID, responses...), mutation, nil
378+}
379+
380+func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([][]byte, *Mutation, error) {
381+ if s.scene == nil {
382+ return nil, nil, ErrNoScene
383+ }
384+ asset, ok := s.scene.Snapshot().Asset(assetID)
385+ if !ok {
386+ return nil, nil, ErrImageNotFound
387+ }
388+ width, height := asset.Width(), asset.Height()
389+ source := graphics.PixelRect{Width: width, Height: height}
390+ var err error
391+ if source.X, err = checkedCoordinate(c.SourceX, c.HasSourceX); err != nil {
392+ return nil, nil, err
393+ }
394+ if source.Y, err = checkedCoordinate(c.SourceY, c.HasSourceY); err != nil {
395+ return nil, nil, err
396+ }
397+ if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
398+ return nil, nil, err
399+ }
400+ if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
401+ return nil, nil, err
402+ }
403+ x, err := checkedCoordinate(c.X, c.HasX)
404+ if err != nil {
405+ return nil, nil, err
406+ }
407+ y, err := checkedCoordinate(c.Y, c.HasY)
408+ if err != nil {
409+ return nil, nil, err
410+ }
411+ destination := graphics.PixelRect{X: x, Y: y, Width: source.Width, Height: source.Height}
412+ if !source.Valid() || !destination.Valid() {
413+ return nil, nil, graphics.ErrInvalidRect
414+ }
415+ var cells graphics.CellRect
416+ if c.HasColumns || c.HasRows {
417+ if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
418+ return nil, nil, fmt.Errorf("%w: c/r", ErrInvalidCommand)
419+ }
420+ cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}
421+ if !cells.Valid() {
422+ return nil, nil, graphics.ErrInvalidRect
423+ }
424+ }
425+ spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells}
426+ if c.HasLayer {
427+ spec.Layer = c.Layer
428+ }
429+ placementID := c.PlacementID
430+ if placementID == 0 {
431+ placementID = s.nextPlacementID()
432+ }
433+ if existing, ok := s.placements[placementID]; ok {
434+ if err := s.scene.UpdatePlacement(existing, spec); err != nil {
435+ return nil, nil, err
436+ }
437+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: existing}, nil
438+ }
439+ if uint64(len(s.placements)) >= s.limits.MaxPlacements {
440+ return nil, nil, graphics.ErrTooManyPlacements
441+ }
442+ sceneID, err := s.scene.Place(spec)
443+ if err != nil {
444+ return nil, nil, err
445+ }
446+ s.placements[placementID] = sceneID
447+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
448+}
449+
450+func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {
451+ id := controlsImageID(c)
452+ if id != 0 {
453+ if _, ok := s.images[id]; !ok {
454+ return s.failure(c, id, ErrImageNotFound)
455+ }
456+ }
457+ return s.success(c, id), nil, nil
458+}
459+
460+func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
461+ target := c.Delete
462+ if !c.HasDelete {
463+ target = DeleteImage
464+ }
465+ id := controlsImageID(c)
466+ switch target {
467+ case DeleteImage, DeleteImageNumber:
468+ asset, ok := s.images[id]
469+ if !ok {
470+ return s.failure(c, id, ErrImageNotFound)
471+ }
472+ if s.scene == nil {
473+ return s.failure(c, id, ErrNoScene)
474+ }
475+ s.removeMappingsForAsset(asset)
476+ if err := s.scene.RemoveAssetCascade(asset); err != nil {
477+ return s.failure(c, id, err)
478+ }
479+ delete(s.images, id)
480+ return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
481+ case DeletePlacement:
482+ placementID := c.PlacementID
483+ mapped, ok := s.placements[placementID]
484+ if !ok || s.scene == nil {
485+ return s.failure(c, id, ErrPlacementNotFound)
486+ }
487+ if err := s.scene.RemovePlacement(mapped); err != nil {
488+ return s.failure(c, id, err)
489+ }
490+ delete(s.placements, placementID)
491+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
492+ case DeleteAll, DeleteAllImages, DeleteAllPlacements:
493+ if s.scene == nil {
494+ return s.failure(c, id, ErrNoScene)
495+ }
496+ if target == DeleteAllPlacements {
497+ for placementID, mapped := range s.placements {
498+ if err := s.scene.RemovePlacement(mapped); err != nil {
499+ return s.failure(c, id, err)
500+ }
501+ delete(s.placements, placementID)
502+ }
503+ } else {
504+ if err := s.scene.Clear(); err != nil {
505+ return s.failure(c, id, err)
506+ }
507+ s.images = make(map[uint64]graphics.AssetID)
508+ s.placements = make(map[uint64]graphics.PlacementID)
509+ s.children = make(map[uint64]Child)
510+ }
511+ return s.success(c, id), &Mutation{Kind: MutationClear}, nil
512+ default:
513+ return s.failure(c, id, ErrUnsupported)
514+ }
515+}
516+
517+func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
518+ for imageID, mapped := range s.images {
519+ if mapped == asset {
520+ delete(s.images, imageID)
521+ }
522+ }
523+ if s.scene != nil {
524+ snapshot := s.scene.Snapshot()
525+ for placementID, mapped := range s.placements {
526+ placement, ok := snapshot.Placement(mapped)
527+ if !ok || placement.AssetID() == asset {
528+ delete(s.placements, placementID)
529+ }
530+ }
531+ }
532+ for childID, child := range s.children {
533+ if child.AssetID == asset {
534+ delete(s.children, childID)
535+ }
536+ }
537+}
538+
539+func continuationControlsAllowed(current, initial Controls) bool {
540+ if current.HasAction {
541+ initialAction := initial.Action
542+ if !initial.HasAction {
543+ initialAction = ActionTransmit
544+ }
545+ if current.Action != initialAction {
546+ return false
547+ }
548+ }
549+ return !current.HasCompression && !current.HasFormat && !current.HasImageID &&
550+ !current.HasImageNumber && !current.HasWidth && !current.HasHeight &&
551+ !current.HasColumns && !current.HasRows && !current.HasX && !current.HasY &&
552+ !current.HasSourceWidth && !current.HasSourceHeight && !current.HasLayer &&
553+ !current.HasPlacementID && !current.HasDelete && !current.HasTransmission &&
554+ !current.HasCursor && !current.HasSourceX && !current.HasSourceY &&
555+ !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
556+}
557+
558+func controlsImageID(c Controls) uint64 {
559+ if c.HasImageID {
560+ return c.ImageID
561+ }
562+ if c.HasImageNumber {
563+ return c.ImageNumber
564+ }
565+ return 0
566+}
567+
568+func (s *Session) nextImageID() uint64 {
569+ for id := uint64(1); ; id++ {
570+ if _, ok := s.images[id]; !ok {
571+ return id
572+ }
573+ }
574+}
575+
576+func (s *Session) nextPlacementID() uint64 {
577+ max := uint64(0)
578+ for id := range s.placements {
579+ if id > max {
580+ max = id
581+ }
582+ }
583+ return max + 1
584+}
585+
586+func checkedCoordinate(n uint64, present bool) (int64, error) {
587+ if !present {
588+ return 0, nil
589+ }
590+ if n > math.MaxInt64 {
591+ return 0, ErrIntegerOverflow
592+ }
593+ return int64(n), nil
594+}
595+
596+func checkedDimension(n uint64, present bool, fallback int64) (int64, error) {
597+ if !present {
598+ return fallback, nil
599+ }
600+ if n > math.MaxInt64 {
601+ return 0, ErrIntegerOverflow
602+ }
603+ return int64(n), nil
604+}
605+
606+func signedCoordinateInt(n int64, present bool) int64 {
607+ if !present {
608+ return 0
609+ }
610+ return n
611+}
612+
613+func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
614+ responses := prior
615+ if c.Quiet != QuietSuccess && c.Quiet != QuietAll {
616+ response := MakeResponse(imageID, "OK")
617+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
618+ responses = append(responses, response)
619+ }
620+ }
621+ return responses
622+}
623+
624+func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
625+ if err == nil {
626+ err = ErrInvalidCommand
627+ }
628+ if c.Quiet != QuietAll {
629+ code := errorCode(err)
630+ response := MakeResponse(imageID, code)
631+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
632+ return [][]byte{response}, nil, err
633+ }
634+ }
635+ return nil, nil, err
636+}
637+
638+func (s *Session) abortUpload(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
639+ s.upload = nil
640+ return s.failure(c, imageID, err)
641+}
642+
643+func errorCode(err error) string {
644+ switch {
645+ case err == nil:
646+ return "OK"
647+ case errorsIs(err, ErrImageNotFound), errorsIs(err, ErrPlacementNotFound), errorsIs(err, graphics.ErrAssetNotFound), errorsIs(err, graphics.ErrPlacementNotFound):
648+ return "ENOENT"
649+ case errorsIs(err, ErrPayloadTooLarge), errorsIs(err, ErrAPCTooLarge), errorsIs(err, graphics.ErrEncodedBudget), errorsIs(err, graphics.ErrDecodedPixelBudget):
650+ return "E2BIG"
651+ case errorsIs(err, ErrUnsupported), errorsIs(err, ErrUnknownAction):
652+ return "ENOTSUP"
653+ default:
654+ return "EINVAL"
655+ }
656+}
657+
658+// errorsIs is kept local to make error-code selection explicit and avoid
659+// leaking any protocol error implementation details in the public API.
660+func errorsIs(err, target error) bool {
661+ for err != nil {
662+ if err == target {
663+ return true
664+ }
665+ type unwrapper interface{ Unwrap() error }
666+ u, ok := err.(unwrapper)
667+ if !ok {
668+ return false
669+ }
670+ err = u.Unwrap()
671+ }
672+ return false
673+}
674+
675+func mergeControls(base, override Controls) Controls {
676+ out := base
677+ if override.HasAction {
678+ out.Action, out.HasAction = override.Action, true
679+ }
680+ if override.HasCompression {
681+ out.Compression, out.HasCompression = override.Compression, true
682+ }
683+ if override.HasFormat {
684+ out.Format, out.HasFormat = override.Format, true
685+ }
686+ if override.HasImageID {
687+ out.ImageID, out.HasImageID = override.ImageID, true
688+ }
689+ if override.HasImageNumber {
690+ out.ImageNumber, out.HasImageNumber = override.ImageNumber, true
691+ }
692+ if override.HasMore {
693+ out.More, out.HasMore = override.More, true
694+ }
695+ if override.HasQuiet {
696+ out.Quiet, out.HasQuiet = override.Quiet, true
697+ }
698+ if override.HasWidth {
699+ out.Width, out.HasWidth = override.Width, true
700+ }
701+ if override.HasHeight {
702+ out.Height, out.HasHeight = override.Height, true
703+ }
704+ if override.HasColumns {
705+ out.Columns, out.HasColumns = override.Columns, true
706+ }
707+ if override.HasRows {
708+ out.Rows, out.HasRows = override.Rows, true
709+ }
710+ if override.HasX {
711+ out.X, out.HasX = override.X, true
712+ }
713+ if override.HasY {
714+ out.Y, out.HasY = override.Y, true
715+ }
716+ if override.HasSourceWidth {
717+ out.SourceWidth, out.HasSourceWidth = override.SourceWidth, true
718+ }
719+ if override.HasSourceHeight {
720+ out.SourceHeight, out.HasSourceHeight = override.SourceHeight, true
721+ }
722+ if override.HasLayer {
723+ out.Layer, out.HasLayer = override.Layer, true
724+ }
725+ if override.HasPlacementID {
726+ out.PlacementID, out.HasPlacementID = override.PlacementID, true
727+ }
728+ if override.HasDelete {
729+ out.Delete, out.HasDelete = override.Delete, true
730+ }
731+ if override.HasTransmission {
732+ out.Transmission, out.HasTransmission = override.Transmission, true
733+ }
734+ if override.HasCursor {
735+ out.Cursor, out.HasCursor = override.Cursor, true
736+ }
737+ if override.HasSourceX {
738+ out.SourceX, out.HasSourceX = override.SourceX, true
739+ }
740+ if override.HasSourceY {
741+ out.SourceY, out.HasSourceY = override.SourceY, true
742+ }
743+ if override.HasCellOffsetX {
744+ out.CellOffsetX, out.HasCellOffsetX = override.CellOffsetX, true
745+ }
746+ if override.HasCellOffsetY {
747+ out.CellOffsetY, out.HasCellOffsetY = override.CellOffsetY, true
748+ }
749+ if override.HasParent {
750+ out.Parent, out.HasParent = override.Parent, true
751+ }
752+ return out
753+}
@@ -0,0 +1,753 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "image/png"
7+ "math"
8+ "strconv"
9+
10+ "github.com/bnema/vev-vt/graphics"
11+)
12+
13+type upload struct {
14+ controls Controls
15+ imageID uint64
16+ payload []byte
17+ chunks uint64
18+}
19+
20+// MutationKind describes a scene mutation performed by a session.
21+type MutationKind uint8
22+
23+const (
24+ MutationUpload MutationKind = iota + 1
25+ MutationPlacement
26+ MutationDeleteImage
27+ MutationDeletePlacement
28+ MutationClear
29+)
30+
31+// Mutation records the stable protocol and opaque scene IDs involved in one
32+// accepted command.
33+type Mutation struct {
34+ Kind MutationKind
35+ ImageID uint64
36+ ChildID uint64
37+ AssetID graphics.AssetID
38+ PlacementID uint64
39+ ScenePlacementID graphics.PlacementID
40+}
41+
42+// Result contains all output from one or more commands in a Feed call.
43+type Result struct {
44+ Events []Event
45+ Responses [][]byte
46+ Mutations []Mutation
47+}
48+
49+// Bytes returns response bytes in stable command order. The returned slice is
50+// newly allocated and may be modified by the caller.
51+func (r Result) Bytes() []byte {
52+ var n int
53+ for _, response := range r.Responses {
54+ n += len(response)
55+ }
56+ out := make([]byte, 0, n)
57+ for _, response := range r.Responses {
58+ out = append(out, response...)
59+ }
60+ return out
61+}
62+
63+// Response is the wire representation of a stable Kitty response.
64+type Response struct {
65+ ImageID uint64
66+ OK bool
67+ Code string
68+ Bytes []byte
69+}
70+
71+const responsePrefix = "\x1b_G"
72+const responseSuffix = "\x1b\\"
73+
74+// MakeResponse constructs the canonical Kitty response used by Session.
75+func MakeResponse(imageID uint64, code string) []byte {
76+ if code == "" {
77+ code = "OK"
78+ }
79+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ";" + code + responseSuffix)
80+}
81+
82+// Feed parses and applies arbitrary bytes. Ordinary text is returned as
83+// EventText; it is never interpreted, discarded, or passed through graphics
84+// scene mutation. A malformed command is reported after all complete events
85+// in the same input have been handled.
86+func (s *Session) Feed(data []byte) (Result, error) {
87+ if s == nil {
88+ return Result{}, ErrNoScene
89+ }
90+ s.mu.Lock()
91+ defer s.mu.Unlock()
92+ var result Result
93+ for _, event := range s.parser.Feed(data) {
94+ result.Events = append(result.Events, event)
95+ if event.Kind != EventCommand {
96+ continue
97+ }
98+ responses, mutation, err := s.apply(event.Command)
99+ if len(responses) != 0 {
100+ result.Responses = append(result.Responses, responses...)
101+ }
102+ if mutation != nil {
103+ result.Mutations = append(result.Mutations, *mutation)
104+ }
105+ if err != nil {
106+ // Keep the error attached to the event as well as returning it
107+ // below. This makes mixed text/graphics feeds inspectable without
108+ // requiring callers to reconstruct command order.
109+ result.Events[len(result.Events)-1].Kind = EventError
110+ result.Events[len(result.Events)-1].Err = err
111+ }
112+ }
113+ var first error
114+ for _, event := range result.Events {
115+ if event.Kind == EventError && event.Err != nil {
116+ first = event.Err
117+ break
118+ }
119+ }
120+ return result, first
121+}
122+
123+// Write is an adapter-oriented alias for Feed.
124+func (s *Session) Write(data []byte) (Result, error) { return s.Feed(data) }
125+
126+// Handle is an adapter-oriented alias for Feed.
127+func (s *Session) Handle(data []byte) (Result, error) { return s.Feed(data) }
128+
129+// Finish flushes an incomplete parser sequence. It never mutates the scene.
130+func (s *Session) Finish() (Result, error) {
131+ if s == nil {
132+ return Result{}, ErrNoScene
133+ }
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ result := Result{}
137+ for _, event := range s.parser.Finish() {
138+ result.Events = append(result.Events, event)
139+ }
140+ if len(result.Events) != 0 {
141+ return result, result.Events[0].Err
142+ }
143+ return result, nil
144+}
145+
146+// Process applies one already parsed command. It is useful for callers that
147+// have their own stream framing but want this package's strict adapter.
148+func (s *Session) Process(command Command) (Result, error) {
149+ if s == nil {
150+ return Result{}, ErrNoScene
151+ }
152+ s.mu.Lock()
153+ defer s.mu.Unlock()
154+ responses, mutation, err := s.apply(command)
155+ result := Result{Responses: responses}
156+ if mutation != nil {
157+ result.Mutations = []Mutation{*mutation}
158+ }
159+ return result, err
160+}
161+
162+func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
163+ c := command.Controls
164+ action := c.Action
165+ if !c.HasAction {
166+ action = ActionTransmit
167+ }
168+ if c.HasCompression && c.Compression != 0 {
169+ return s.failure(c, controlsImageID(c), ErrUnsupported)
170+ }
171+ if c.HasTransmission && c.Transmission != TransmissionDirect {
172+ return s.failure(c, controlsImageID(c), ErrUnsupported)
173+ }
174+ if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
175+ return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
176+ }
177+ switch action {
178+ case ActionTransmit, ActionTransmitDisplay:
179+ return s.transmit(command, action == ActionTransmitDisplay)
180+ case ActionPut:
181+ if len(command.Payload) != 0 {
182+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
183+ }
184+ return s.put(c)
185+ case ActionQuery:
186+ if len(command.Payload) != 0 {
187+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
188+ }
189+ return s.query(c)
190+ case ActionDelete:
191+ if len(command.Payload) != 0 {
192+ return s.failure(c, controlsImageID(c), ErrInvalidCommand)
193+ }
194+ return s.delete(c)
195+ case ActionFrame, ActionCompose:
196+ return s.failure(c, controlsImageID(c), ErrUnsupported)
197+ default:
198+ return s.failure(c, controlsImageID(c), ErrUnknownAction)
199+ }
200+}
201+
202+func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
203+ c := command.Controls
204+ imageID := controlsImageID(c)
205+ if s.upload != nil {
206+ if !continuationControlsAllowed(c, s.upload.controls) {
207+ return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
208+ }
209+ hasImageSelector := c.HasImageID || c.HasImageNumber
210+ if hasImageSelector && imageID != 0 && imageID != s.upload.imageID {
211+ return s.failure(c, imageID, ErrInterleavedUpload)
212+ }
213+ if hasImageSelector && imageID == 0 {
214+ return s.failure(c, s.upload.imageID, ErrInterleavedUpload)
215+ }
216+ imageID = s.upload.imageID
217+ if s.upload.chunks >= s.limits.MaxChunks {
218+ return s.abortUpload(c, imageID, ErrTooManyChunks)
219+ }
220+ if uint64(len(s.upload.payload))+uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
221+ return s.abortUpload(c, imageID, ErrPayloadTooLarge)
222+ }
223+ s.upload.payload = append(s.upload.payload, command.Payload...)
224+ s.upload.chunks++
225+ s.upload.controls = mergeControls(s.upload.controls, c)
226+ if c.HasMore && c.More == 1 {
227+ return nil, nil, nil
228+ }
229+ complete := *s.upload
230+ s.upload = nil
231+ complete.controls = mergeControls(complete.controls, c)
232+ return s.commitUpload(complete, display)
233+ }
234+
235+ if imageID == 0 {
236+ imageID = s.nextImageID()
237+ }
238+ if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
239+ return s.failure(c, imageID, ErrPayloadTooLarge)
240+ }
241+ if c.HasMore && c.More == 1 {
242+ s.upload = &upload{
243+ controls: c,
244+ imageID: imageID,
245+ payload: append([]byte(nil), command.Payload...),
246+ chunks: 1,
247+ }
248+ return nil, nil, nil
249+ }
250+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1}, display)
251+}
252+
253+func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
254+ if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
255+ return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
256+ }
257+ decoded, err := DecodeBase64(value.payload)
258+ if err != nil {
259+ return s.failure(value.controls, value.imageID, err)
260+ }
261+ format := value.controls.Format
262+ if !value.controls.HasFormat {
263+ format = FormatRGBA
264+ }
265+ width, height, err := imageGeometry(decoded, format, value.controls, s.limits)
266+ if err != nil {
267+ return s.failure(value.controls, value.imageID, err)
268+ }
269+ if s.scene == nil {
270+ return s.failure(value.controls, value.imageID, ErrNoScene)
271+ }
272+ if old, ok := s.images[value.imageID]; ok {
273+ // Remove adapter references while the old scene snapshot still
274+ // contains its placements, then remove the scene asset atomically.
275+ s.removeMappingsForAsset(old)
276+ if err := s.scene.RemoveAssetCascade(old); err != nil {
277+ return s.failure(value.controls, value.imageID, err)
278+ }
279+ }
280+ if uint64(len(s.images)) >= s.limits.MaxImages {
281+ return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
282+ }
283+ assetID, err := s.scene.AddAsset(graphics.AssetBlob{
284+ Encoded: decoded,
285+ Width: width,
286+ Height: height,
287+ })
288+ if err != nil {
289+ return s.failure(value.controls, value.imageID, err)
290+ }
291+ s.images[value.imageID] = assetID
292+ childID := s.nextChild
293+ s.nextChild++
294+ s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
295+ mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
296+ if display {
297+ responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID)
298+ if placementMutation != nil {
299+ mutation.Kind = MutationPlacement
300+ mutation.PlacementID = placementMutation.PlacementID
301+ mutation.ScenePlacementID = placementMutation.ScenePlacementID
302+ }
303+ if placeErr != nil {
304+ // T (transmit and display) is one protocol operation. Do not
305+ // leave an uploaded asset behind when its placement is rejected.
306+ _ = s.scene.RemoveAssetCascade(assetID)
307+ delete(s.images, value.imageID)
308+ delete(s.children, childID)
309+ return responses, nil, placeErr
310+ }
311+ return s.success(value.controls, value.imageID, responses...), mutation, nil
312+ }
313+ return s.success(value.controls, value.imageID), mutation, nil
314+}
315+
316+func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64, int64, error) {
317+ if format == FormatPNG {
318+ config, err := png.DecodeConfig(bytes.NewReader(data))
319+ if err != nil {
320+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
321+ }
322+ if config.Width <= 0 || config.Height <= 0 {
323+ return 0, 0, ErrInvalidPNG
324+ }
325+ if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
326+ return 0, 0, ErrPayloadTooLarge
327+ }
328+ if uint64(config.Width) > math.MaxInt64/uint64(config.Height) || uint64(config.Width)*uint64(config.Height) > limits.MaxDecodedPixels {
329+ return 0, 0, ErrPayloadTooLarge
330+ }
331+ if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
332+ return 0, 0, fmt.Errorf("%w: PNG dimensions do not match controls", ErrInvalidPNG)
333+ }
334+ // Decode, rather than only DecodeConfig, so malformed/truncated IDAT
335+ // data and CRC failures are rejected by the standard library.
336+ if _, err := png.Decode(bytes.NewReader(data)); err != nil {
337+ return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
338+ }
339+ return int64(config.Width), int64(config.Height), nil
340+ }
341+ if !format.Valid() {
342+ return 0, 0, ErrUnsupported
343+ }
344+ if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
345+ return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
346+ }
347+ if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension || c.Width > math.MaxInt64/c.Height || c.Width*c.Height > limits.MaxDecodedPixels {
348+ return 0, 0, ErrPayloadTooLarge
349+ }
350+ channels := uint64(3)
351+ if format == FormatRGBA {
352+ channels = 4
353+ }
354+ if c.Width > math.MaxUint64/(c.Height*channels) {
355+ return 0, 0, ErrPayloadTooLarge
356+ }
357+ expected := c.Width * c.Height * channels
358+ if uint64(len(data)) != expected {
359+ return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
360+ }
361+ return int64(c.Width), int64(c.Height), nil
362+}
363+
364+func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
365+ imageID := controlsImageID(c)
366+ if imageID == 0 {
367+ return s.failure(c, imageID, ErrImageNotFound)
368+ }
369+ assetID, ok := s.images[imageID]
370+ if !ok {
371+ return s.failure(c, imageID, ErrImageNotFound)
372+ }
373+ responses, mutation, err := s.place(c, imageID, assetID)
374+ if err != nil {
375+ return responses, mutation, err
376+ }
377+ return s.success(c, imageID, responses...), mutation, nil
378+}
379+
380+func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([][]byte, *Mutation, error) {
381+ if s.scene == nil {
382+ return nil, nil, ErrNoScene
383+ }
384+ asset, ok := s.scene.Snapshot().Asset(assetID)
385+ if !ok {
386+ return nil, nil, ErrImageNotFound
387+ }
388+ width, height := asset.Width(), asset.Height()
389+ source := graphics.PixelRect{Width: width, Height: height}
390+ var err error
391+ if source.X, err = checkedCoordinate(c.SourceX, c.HasSourceX); err != nil {
392+ return nil, nil, err
393+ }
394+ if source.Y, err = checkedCoordinate(c.SourceY, c.HasSourceY); err != nil {
395+ return nil, nil, err
396+ }
397+ if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
398+ return nil, nil, err
399+ }
400+ if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
401+ return nil, nil, err
402+ }
403+ x, err := checkedCoordinate(c.X, c.HasX)
404+ if err != nil {
405+ return nil, nil, err
406+ }
407+ y, err := checkedCoordinate(c.Y, c.HasY)
408+ if err != nil {
409+ return nil, nil, err
410+ }
411+ destination := graphics.PixelRect{X: x, Y: y, Width: source.Width, Height: source.Height}
412+ if !source.Valid() || !destination.Valid() {
413+ return nil, nil, graphics.ErrInvalidRect
414+ }
415+ var cells graphics.CellRect
416+ if c.HasColumns || c.HasRows {
417+ if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
418+ return nil, nil, fmt.Errorf("%w: c/r", ErrInvalidCommand)
419+ }
420+ cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}
421+ if !cells.Valid() {
422+ return nil, nil, graphics.ErrInvalidRect
423+ }
424+ }
425+ spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells}
426+ if c.HasLayer {
427+ spec.Layer = c.Layer
428+ }
429+ placementID := c.PlacementID
430+ if placementID == 0 {
431+ placementID = s.nextPlacementID()
432+ }
433+ if existing, ok := s.placements[placementID]; ok {
434+ if err := s.scene.UpdatePlacement(existing, spec); err != nil {
435+ return nil, nil, err
436+ }
437+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: existing}, nil
438+ }
439+ if uint64(len(s.placements)) >= s.limits.MaxPlacements {
440+ return nil, nil, graphics.ErrTooManyPlacements
441+ }
442+ sceneID, err := s.scene.Place(spec)
443+ if err != nil {
444+ return nil, nil, err
445+ }
446+ s.placements[placementID] = sceneID
447+ return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
448+}
449+
450+func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {
451+ id := controlsImageID(c)
452+ if id != 0 {
453+ if _, ok := s.images[id]; !ok {
454+ return s.failure(c, id, ErrImageNotFound)
455+ }
456+ }
457+ return s.success(c, id), nil, nil
458+}
459+
460+func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
461+ target := c.Delete
462+ if !c.HasDelete {
463+ target = DeleteImage
464+ }
465+ id := controlsImageID(c)
466+ switch target {
467+ case DeleteImage, DeleteImageNumber:
468+ asset, ok := s.images[id]
469+ if !ok {
470+ return s.failure(c, id, ErrImageNotFound)
471+ }
472+ if s.scene == nil {
473+ return s.failure(c, id, ErrNoScene)
474+ }
475+ s.removeMappingsForAsset(asset)
476+ if err := s.scene.RemoveAssetCascade(asset); err != nil {
477+ return s.failure(c, id, err)
478+ }
479+ delete(s.images, id)
480+ return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
481+ case DeletePlacement:
482+ placementID := c.PlacementID
483+ mapped, ok := s.placements[placementID]
484+ if !ok || s.scene == nil {
485+ return s.failure(c, id, ErrPlacementNotFound)
486+ }
487+ if err := s.scene.RemovePlacement(mapped); err != nil {
488+ return s.failure(c, id, err)
489+ }
490+ delete(s.placements, placementID)
491+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
492+ case DeleteAll, DeleteAllImages, DeleteAllPlacements:
493+ if s.scene == nil {
494+ return s.failure(c, id, ErrNoScene)
495+ }
496+ if target == DeleteAllPlacements {
497+ for placementID, mapped := range s.placements {
498+ if err := s.scene.RemovePlacement(mapped); err != nil {
499+ return s.failure(c, id, err)
500+ }
501+ delete(s.placements, placementID)
502+ }
503+ } else {
504+ if err := s.scene.Clear(); err != nil {
505+ return s.failure(c, id, err)
506+ }
507+ s.images = make(map[uint64]graphics.AssetID)
508+ s.placements = make(map[uint64]graphics.PlacementID)
509+ s.children = make(map[uint64]Child)
510+ }
511+ return s.success(c, id), &Mutation{Kind: MutationClear}, nil
512+ default:
513+ return s.failure(c, id, ErrUnsupported)
514+ }
515+}
516+
517+func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
518+ for imageID, mapped := range s.images {
519+ if mapped == asset {
520+ delete(s.images, imageID)
521+ }
522+ }
523+ if s.scene != nil {
524+ snapshot := s.scene.Snapshot()
525+ for placementID, mapped := range s.placements {
526+ placement, ok := snapshot.Placement(mapped)
527+ if !ok || placement.AssetID() == asset {
528+ delete(s.placements, placementID)
529+ }
530+ }
531+ }
532+ for childID, child := range s.children {
533+ if child.AssetID == asset {
534+ delete(s.children, childID)
535+ }
536+ }
537+}
538+
539+func continuationControlsAllowed(current, initial Controls) bool {
540+ if current.HasAction {
541+ initialAction := initial.Action
542+ if !initial.HasAction {
543+ initialAction = ActionTransmit
544+ }
545+ if current.Action != initialAction {
546+ return false
547+ }
548+ }
549+ return !current.HasCompression && !current.HasFormat && !current.HasImageID &&
550+ !current.HasImageNumber && !current.HasWidth && !current.HasHeight &&
551+ !current.HasColumns && !current.HasRows && !current.HasX && !current.HasY &&
552+ !current.HasSourceWidth && !current.HasSourceHeight && !current.HasLayer &&
553+ !current.HasPlacementID && !current.HasDelete && !current.HasTransmission &&
554+ !current.HasCursor && !current.HasSourceX && !current.HasSourceY &&
555+ !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
556+}
557+
558+func controlsImageID(c Controls) uint64 {
559+ if c.HasImageID {
560+ return c.ImageID
561+ }
562+ if c.HasImageNumber {
563+ return c.ImageNumber
564+ }
565+ return 0
566+}
567+
568+func (s *Session) nextImageID() uint64 {
569+ for id := uint64(1); ; id++ {
570+ if _, ok := s.images[id]; !ok {
571+ return id
572+ }
573+ }
574+}
575+
576+func (s *Session) nextPlacementID() uint64 {
577+ max := uint64(0)
578+ for id := range s.placements {
579+ if id > max {
580+ max = id
581+ }
582+ }
583+ return max + 1
584+}
585+
586+func checkedCoordinate(n uint64, present bool) (int64, error) {
587+ if !present {
588+ return 0, nil
589+ }
590+ if n > math.MaxInt64 {
591+ return 0, ErrIntegerOverflow
592+ }
593+ return int64(n), nil
594+}
595+
596+func checkedDimension(n uint64, present bool, fallback int64) (int64, error) {
597+ if !present {
598+ return fallback, nil
599+ }
600+ if n > math.MaxInt64 {
601+ return 0, ErrIntegerOverflow
602+ }
603+ return int64(n), nil
604+}
605+
606+func signedCoordinateInt(n int64, present bool) int64 {
607+ if !present {
608+ return 0
609+ }
610+ return n
611+}
612+
613+func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
614+ responses := prior
615+ if c.Quiet != QuietSuccess && c.Quiet != QuietAll {
616+ response := MakeResponse(imageID, "OK")
617+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
618+ responses = append(responses, response)
619+ }
620+ }
621+ return responses
622+}
623+
624+func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
625+ if err == nil {
626+ err = ErrInvalidCommand
627+ }
628+ if c.Quiet != QuietAll {
629+ code := errorCode(err)
630+ response := MakeResponse(imageID, code)
631+ if uint64(len(response)) <= s.limits.MaxResponseBytes {
632+ return [][]byte{response}, nil, err
633+ }
634+ }
635+ return nil, nil, err
636+}
637+
638+func (s *Session) abortUpload(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
639+ s.upload = nil
640+ return s.failure(c, imageID, err)
641+}
642+
643+func errorCode(err error) string {
644+ switch {
645+ case err == nil:
646+ return "OK"
647+ case errorsIs(err, ErrImageNotFound), errorsIs(err, ErrPlacementNotFound), errorsIs(err, graphics.ErrAssetNotFound), errorsIs(err, graphics.ErrPlacementNotFound):
648+ return "ENOENT"
649+ case errorsIs(err, ErrPayloadTooLarge), errorsIs(err, ErrAPCTooLarge), errorsIs(err, graphics.ErrEncodedBudget), errorsIs(err, graphics.ErrDecodedPixelBudget):
650+ return "E2BIG"
651+ case errorsIs(err, ErrUnsupported), errorsIs(err, ErrUnknownAction):
652+ return "ENOTSUP"
653+ default:
654+ return "EINVAL"
655+ }
656+}
657+
658+// errorsIs is kept local to make error-code selection explicit and avoid
659+// leaking any protocol error implementation details in the public API.
660+func errorsIs(err, target error) bool {
661+ for err != nil {
662+ if err == target {
663+ return true
664+ }
665+ type unwrapper interface{ Unwrap() error }
666+ u, ok := err.(unwrapper)
667+ if !ok {
668+ return false
669+ }
670+ err = u.Unwrap()
671+ }
672+ return false
673+}
674+
675+func mergeControls(base, override Controls) Controls {
676+ out := base
677+ if override.HasAction {
678+ out.Action, out.HasAction = override.Action, true
679+ }
680+ if override.HasCompression {
681+ out.Compression, out.HasCompression = override.Compression, true
682+ }
683+ if override.HasFormat {
684+ out.Format, out.HasFormat = override.Format, true
685+ }
686+ if override.HasImageID {
687+ out.ImageID, out.HasImageID = override.ImageID, true
688+ }
689+ if override.HasImageNumber {
690+ out.ImageNumber, out.HasImageNumber = override.ImageNumber, true
691+ }
692+ if override.HasMore {
693+ out.More, out.HasMore = override.More, true
694+ }
695+ if override.HasQuiet {
696+ out.Quiet, out.HasQuiet = override.Quiet, true
697+ }
698+ if override.HasWidth {
699+ out.Width, out.HasWidth = override.Width, true
700+ }
701+ if override.HasHeight {
702+ out.Height, out.HasHeight = override.Height, true
703+ }
704+ if override.HasColumns {
705+ out.Columns, out.HasColumns = override.Columns, true
706+ }
707+ if override.HasRows {
708+ out.Rows, out.HasRows = override.Rows, true
709+ }
710+ if override.HasX {
711+ out.X, out.HasX = override.X, true
712+ }
713+ if override.HasY {
714+ out.Y, out.HasY = override.Y, true
715+ }
716+ if override.HasSourceWidth {
717+ out.SourceWidth, out.HasSourceWidth = override.SourceWidth, true
718+ }
719+ if override.HasSourceHeight {
720+ out.SourceHeight, out.HasSourceHeight = override.SourceHeight, true
721+ }
722+ if override.HasLayer {
723+ out.Layer, out.HasLayer = override.Layer, true
724+ }
725+ if override.HasPlacementID {
726+ out.PlacementID, out.HasPlacementID = override.PlacementID, true
727+ }
728+ if override.HasDelete {
729+ out.Delete, out.HasDelete = override.Delete, true
730+ }
731+ if override.HasTransmission {
732+ out.Transmission, out.HasTransmission = override.Transmission, true
733+ }
734+ if override.HasCursor {
735+ out.Cursor, out.HasCursor = override.Cursor, true
736+ }
737+ if override.HasSourceX {
738+ out.SourceX, out.HasSourceX = override.SourceX, true
739+ }
740+ if override.HasSourceY {
741+ out.SourceY, out.HasSourceY = override.SourceY, true
742+ }
743+ if override.HasCellOffsetX {
744+ out.CellOffsetX, out.HasCellOffsetX = override.CellOffsetX, true
745+ }
746+ if override.HasCellOffsetY {
747+ out.CellOffsetY, out.HasCellOffsetY = override.CellOffsetY, true
748+ }
749+ if override.HasParent {
750+ out.Parent, out.HasParent = override.Parent, true
751+ }
752+ return out
753+}
A protocol/kittygraphics/testdata/kitten-icat-stream-chunk.bin +1 -0 too large to show in the browser

too large to show in the browser view raw

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