feat(module): control lines
A script can print an OSC 777 neferbar line with a JSON tooltip, menu or close request. The line never becomes a frame; ParseControl enforces the size, depth, item and label limits.
- commit
- 476c4a39929a021c7b33cc662b0ee8b33a8190d1
- author
- brice · · 2026-10-09 04:40 UTC
- parent
- 77cf70f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +424 -14
A internal/module/control.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | package module | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "unicode" | |
| 8 | + | "unicode/utf8" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Limits of a control line. ParseControl refuses what exceeds them. | |
| 12 | + | const ( | |
| 13 | + | // MaxMenuDepth is how many levels of submenus a menu may nest. | |
| 14 | + | MaxMenuDepth = 8 | |
| 15 | + | // MaxMenuItems is how many items a whole menu may hold, submenus included. | |
| 16 | + | MaxMenuItems = 512 | |
| 17 | + | // MaxLabel is the longest item label or tooltip title, in bytes; longer | |
| 18 | + | // ones are cut at a rune boundary. | |
| 19 | + | MaxLabel = 256 | |
| 20 | + | // MaxText is the longest tooltip body, in bytes; longer ones are cut at a | |
| 21 | + | // rune boundary. | |
| 22 | + | MaxText = 1024 | |
| 23 | + | // maxColumn bounds the column and width of a control line. | |
| 24 | + | maxColumn = 1 << 20 | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Control types. | |
| 28 | + | const ( | |
| 29 | + | ControlTooltip = "tooltip" | |
| 30 | + | ControlMenu = "menu" | |
| 31 | + | ControlClose = "close" | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // Menu item kinds. | |
| 35 | + | const ( | |
| 36 | + | KindNormal = "normal" | |
| 37 | + | KindSeparator = "separator" | |
| 38 | + | KindCheck = "check" | |
| 39 | + | KindRadio = "radio" | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // Control is one parsed control line of a script. | |
| 43 | + | type Control struct { | |
| 44 | + | Type string | |
| 45 | + | Col int // first cell of the anchor inside the module | |
| 46 | + | Width int // cells of the anchor | |
| 47 | + | // Title and Body are the text of a tooltip. | |
| 48 | + | Title, Body string | |
| 49 | + | // Click is the token of the press a menu answers. | |
| 50 | + | Click uint32 | |
| 51 | + | Items []MenuItem | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // MenuItem is one entry of a menu. | |
| 55 | + | type MenuItem struct { | |
| 56 | + | ID int32 | |
| 57 | + | Label string | |
| 58 | + | Kind string | |
| 59 | + | Enabled bool | |
| 60 | + | Checked bool | |
| 61 | + | Items []MenuItem // a submenu | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type wireControl struct { | |
| 65 | + | Type string `json:"type"` | |
| 66 | + | Col int `json:"col"` | |
| 67 | + | Width int `json:"width"` | |
| 68 | + | Title string `json:"title"` | |
| 69 | + | Body string `json:"body"` | |
| 70 | + | Click uint32 `json:"click"` | |
| 71 | + | Items []wireItem `json:"items"` | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | type wireItem struct { | |
| 75 | + | ID int32 `json:"id"` | |
| 76 | + | Label string `json:"label"` | |
| 77 | + | Kind string `json:"kind"` | |
| 78 | + | Enabled *bool `json:"enabled"` | |
| 79 | + | Checked bool `json:"checked"` | |
| 80 | + | Items []wireItem `json:"items"` | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // ParseControl reads the JSON of a control line (see TakeControl) and checks | |
| 84 | + | // every limit. Control characters are removed from the texts, labels are cut | |
| 85 | + | // to MaxLabel bytes, and an item without "enabled" is enabled. | |
| 86 | + | func ParseControl(b []byte) (Control, error) { | |
| 87 | + | if len(b) > MaxControl { | |
| 88 | + | return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl) | |
| 89 | + | } | |
| 90 | + | var w wireControl | |
| 91 | + | if err := json.Unmarshal(b, &w); err != nil { | |
| 92 | + | return Control{}, fmt.Errorf("control line: %w", err) | |
| 93 | + | } | |
| 94 | + | if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn { | |
| 95 | + | return Control{}, errors.New("control line: column or width out of range") | |
| 96 | + | } | |
| 97 | + | c := Control{Type: w.Type, Col: w.Col, Width: w.Width} | |
| 98 | + | switch w.Type { | |
| 99 | + | case ControlClose: | |
| 100 | + | return c, nil | |
| 101 | + | case ControlTooltip: | |
| 102 | + | if w.Width == 0 { | |
| 103 | + | return Control{}, errors.New("tooltip: width must be positive") | |
| 104 | + | } | |
| 105 | + | c.Title = clean(w.Title, MaxLabel, false) | |
| 106 | + | c.Body = clean(w.Body, MaxText, true) | |
| 107 | + | if c.Title == "" && c.Body == "" { | |
| 108 | + | return Control{}, errors.New("tooltip: no text") | |
| 109 | + | } | |
| 110 | + | return c, nil | |
| 111 | + | case ControlMenu: | |
| 112 | + | if w.Width == 0 { | |
| 113 | + | return Control{}, errors.New("menu: width must be positive") | |
| 114 | + | } | |
| 115 | + | c.Click = w.Click | |
| 116 | + | count := 0 | |
| 117 | + | items, err := convertItems(w.Items, 1, &count) | |
| 118 | + | if err != nil { | |
| 119 | + | return Control{}, fmt.Errorf("menu: %w", err) | |
| 120 | + | } | |
| 121 | + | if len(items) == 0 { | |
| 122 | + | return Control{}, errors.New("menu: no items") | |
| 123 | + | } | |
| 124 | + | c.Items = items | |
| 125 | + | return c, nil | |
| 126 | + | } | |
| 127 | + | return Control{}, fmt.Errorf("control line: unknown type %q", w.Type) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) { | |
| 131 | + | if len(in) == 0 { | |
| 132 | + | return nil, nil | |
| 133 | + | } | |
| 134 | + | if depth > MaxMenuDepth { | |
| 135 | + | return nil, fmt.Errorf("more than %d levels", MaxMenuDepth) | |
| 136 | + | } | |
| 137 | + | out := make([]MenuItem, 0, len(in)) | |
| 138 | + | for i := range in { | |
| 139 | + | if *count++; *count > MaxMenuItems { | |
| 140 | + | return nil, fmt.Errorf("more than %d items", MaxMenuItems) | |
| 141 | + | } | |
| 142 | + | w := &in[i] | |
| 143 | + | it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked} | |
| 144 | + | if w.ID < 0 { | |
| 145 | + | return nil, fmt.Errorf("item %d: negative id", w.ID) | |
| 146 | + | } | |
| 147 | + | switch w.Kind { | |
| 148 | + | case "": | |
| 149 | + | it.Kind = KindNormal | |
| 150 | + | case KindNormal, KindSeparator, KindCheck, KindRadio: | |
| 151 | + | default: | |
| 152 | + | return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind) | |
| 153 | + | } | |
| 154 | + | sub, err := convertItems(w.Items, depth+1, count) | |
| 155 | + | if err != nil { | |
| 156 | + | return nil, err | |
| 157 | + | } | |
| 158 | + | it.Items = sub | |
| 159 | + | out = append(out, it) | |
| 160 | + | } | |
| 161 | + | return out, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // clean drops control characters (and anything Unicode calls a format or | |
| 165 | + | // unassigned control, such as bidi overrides), keeps newlines when keepNL, and | |
| 166 | + | // cuts the result to max bytes at a rune boundary. | |
| 167 | + | func clean(s string, max int, keepNL bool) string { | |
| 168 | + | needs := len(s) > max | |
| 169 | + | for _, r := range s { | |
| 170 | + | if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { | |
| 171 | + | needs = true | |
| 172 | + | break | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if !needs { | |
| 176 | + | return s | |
| 177 | + | } | |
| 178 | + | out := make([]byte, 0, min(len(s), max)) | |
| 179 | + | for _, r := range s { | |
| 180 | + | switch { | |
| 181 | + | case r == '\n' && keepNL: | |
| 182 | + | case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r): | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | if len(out)+utf8.RuneLen(r) > max { | |
| 186 | + | break | |
| 187 | + | } | |
| 188 | + | out = utf8.AppendRune(out, r) | |
| 189 | + | } | |
| 190 | + | return string(out) | |
| 191 | + | } | |
| @@ -0,0 +1,191 @@ | |||||
| 1 | + | package module | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "unicode" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Limits of a control line. ParseControl refuses what exceeds them. | |||
| 12 | + | const ( | |||
| 13 | + | // MaxMenuDepth is how many levels of submenus a menu may nest. | |||
| 14 | + | MaxMenuDepth = 8 | |||
| 15 | + | // MaxMenuItems is how many items a whole menu may hold, submenus included. | |||
| 16 | + | MaxMenuItems = 512 | |||
| 17 | + | // MaxLabel is the longest item label or tooltip title, in bytes; longer | |||
| 18 | + | // ones are cut at a rune boundary. | |||
| 19 | + | MaxLabel = 256 | |||
| 20 | + | // MaxText is the longest tooltip body, in bytes; longer ones are cut at a | |||
| 21 | + | // rune boundary. | |||
| 22 | + | MaxText = 1024 | |||
| 23 | + | // maxColumn bounds the column and width of a control line. | |||
| 24 | + | maxColumn = 1 << 20 | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Control types. | |||
| 28 | + | const ( | |||
| 29 | + | ControlTooltip = "tooltip" | |||
| 30 | + | ControlMenu = "menu" | |||
| 31 | + | ControlClose = "close" | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // Menu item kinds. | |||
| 35 | + | const ( | |||
| 36 | + | KindNormal = "normal" | |||
| 37 | + | KindSeparator = "separator" | |||
| 38 | + | KindCheck = "check" | |||
| 39 | + | KindRadio = "radio" | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | // Control is one parsed control line of a script. | |||
| 43 | + | type Control struct { | |||
| 44 | + | Type string | |||
| 45 | + | Col int // first cell of the anchor inside the module | |||
| 46 | + | Width int // cells of the anchor | |||
| 47 | + | // Title and Body are the text of a tooltip. | |||
| 48 | + | Title, Body string | |||
| 49 | + | // Click is the token of the press a menu answers. | |||
| 50 | + | Click uint32 | |||
| 51 | + | Items []MenuItem | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // MenuItem is one entry of a menu. | |||
| 55 | + | type MenuItem struct { | |||
| 56 | + | ID int32 | |||
| 57 | + | Label string | |||
| 58 | + | Kind string | |||
| 59 | + | Enabled bool | |||
| 60 | + | Checked bool | |||
| 61 | + | Items []MenuItem // a submenu | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | type wireControl struct { | |||
| 65 | + | Type string `json:"type"` | |||
| 66 | + | Col int `json:"col"` | |||
| 67 | + | Width int `json:"width"` | |||
| 68 | + | Title string `json:"title"` | |||
| 69 | + | Body string `json:"body"` | |||
| 70 | + | Click uint32 `json:"click"` | |||
| 71 | + | Items []wireItem `json:"items"` | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | type wireItem struct { | |||
| 75 | + | ID int32 `json:"id"` | |||
| 76 | + | Label string `json:"label"` | |||
| 77 | + | Kind string `json:"kind"` | |||
| 78 | + | Enabled *bool `json:"enabled"` | |||
| 79 | + | Checked bool `json:"checked"` | |||
| 80 | + | Items []wireItem `json:"items"` | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // ParseControl reads the JSON of a control line (see TakeControl) and checks | |||
| 84 | + | // every limit. Control characters are removed from the texts, labels are cut | |||
| 85 | + | // to MaxLabel bytes, and an item without "enabled" is enabled. | |||
| 86 | + | func ParseControl(b []byte) (Control, error) { | |||
| 87 | + | if len(b) > MaxControl { | |||
| 88 | + | return Control{}, fmt.Errorf("control line of %d bytes exceeds %d", len(b), MaxControl) | |||
| 89 | + | } | |||
| 90 | + | var w wireControl | |||
| 91 | + | if err := json.Unmarshal(b, &w); err != nil { | |||
| 92 | + | return Control{}, fmt.Errorf("control line: %w", err) | |||
| 93 | + | } | |||
| 94 | + | if w.Col < 0 || w.Col > maxColumn || w.Width < 0 || w.Width > maxColumn { | |||
| 95 | + | return Control{}, errors.New("control line: column or width out of range") | |||
| 96 | + | } | |||
| 97 | + | c := Control{Type: w.Type, Col: w.Col, Width: w.Width} | |||
| 98 | + | switch w.Type { | |||
| 99 | + | case ControlClose: | |||
| 100 | + | return c, nil | |||
| 101 | + | case ControlTooltip: | |||
| 102 | + | if w.Width == 0 { | |||
| 103 | + | return Control{}, errors.New("tooltip: width must be positive") | |||
| 104 | + | } | |||
| 105 | + | c.Title = clean(w.Title, MaxLabel, false) | |||
| 106 | + | c.Body = clean(w.Body, MaxText, true) | |||
| 107 | + | if c.Title == "" && c.Body == "" { | |||
| 108 | + | return Control{}, errors.New("tooltip: no text") | |||
| 109 | + | } | |||
| 110 | + | return c, nil | |||
| 111 | + | case ControlMenu: | |||
| 112 | + | if w.Width == 0 { | |||
| 113 | + | return Control{}, errors.New("menu: width must be positive") | |||
| 114 | + | } | |||
| 115 | + | c.Click = w.Click | |||
| 116 | + | count := 0 | |||
| 117 | + | items, err := convertItems(w.Items, 1, &count) | |||
| 118 | + | if err != nil { | |||
| 119 | + | return Control{}, fmt.Errorf("menu: %w", err) | |||
| 120 | + | } | |||
| 121 | + | if len(items) == 0 { | |||
| 122 | + | return Control{}, errors.New("menu: no items") | |||
| 123 | + | } | |||
| 124 | + | c.Items = items | |||
| 125 | + | return c, nil | |||
| 126 | + | } | |||
| 127 | + | return Control{}, fmt.Errorf("control line: unknown type %q", w.Type) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func convertItems(in []wireItem, depth int, count *int) ([]MenuItem, error) { | |||
| 131 | + | if len(in) == 0 { | |||
| 132 | + | return nil, nil | |||
| 133 | + | } | |||
| 134 | + | if depth > MaxMenuDepth { | |||
| 135 | + | return nil, fmt.Errorf("more than %d levels", MaxMenuDepth) | |||
| 136 | + | } | |||
| 137 | + | out := make([]MenuItem, 0, len(in)) | |||
| 138 | + | for i := range in { | |||
| 139 | + | if *count++; *count > MaxMenuItems { | |||
| 140 | + | return nil, fmt.Errorf("more than %d items", MaxMenuItems) | |||
| 141 | + | } | |||
| 142 | + | w := &in[i] | |||
| 143 | + | it := MenuItem{ID: w.ID, Label: clean(w.Label, MaxLabel, false), Kind: w.Kind, Enabled: w.Enabled == nil || *w.Enabled, Checked: w.Checked} | |||
| 144 | + | if w.ID < 0 { | |||
| 145 | + | return nil, fmt.Errorf("item %d: negative id", w.ID) | |||
| 146 | + | } | |||
| 147 | + | switch w.Kind { | |||
| 148 | + | case "": | |||
| 149 | + | it.Kind = KindNormal | |||
| 150 | + | case KindNormal, KindSeparator, KindCheck, KindRadio: | |||
| 151 | + | default: | |||
| 152 | + | return nil, fmt.Errorf("item %d: unknown kind %q", w.ID, w.Kind) | |||
| 153 | + | } | |||
| 154 | + | sub, err := convertItems(w.Items, depth+1, count) | |||
| 155 | + | if err != nil { | |||
| 156 | + | return nil, err | |||
| 157 | + | } | |||
| 158 | + | it.Items = sub | |||
| 159 | + | out = append(out, it) | |||
| 160 | + | } | |||
| 161 | + | return out, nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // clean drops control characters (and anything Unicode calls a format or | |||
| 165 | + | // unassigned control, such as bidi overrides), keeps newlines when keepNL, and | |||
| 166 | + | // cuts the result to max bytes at a rune boundary. | |||
| 167 | + | func clean(s string, max int, keepNL bool) string { | |||
| 168 | + | needs := len(s) > max | |||
| 169 | + | for _, r := range s { | |||
| 170 | + | if r == utf8.RuneError || unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { | |||
| 171 | + | needs = true | |||
| 172 | + | break | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if !needs { | |||
| 176 | + | return s | |||
| 177 | + | } | |||
| 178 | + | out := make([]byte, 0, min(len(s), max)) | |||
| 179 | + | for _, r := range s { | |||
| 180 | + | switch { | |||
| 181 | + | case r == '\n' && keepNL: | |||
| 182 | + | case r == utf8.RuneError, unicode.IsControl(r), unicode.Is(unicode.Cf, r): | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | if len(out)+utf8.RuneLen(r) > max { | |||
| 186 | + | break | |||
| 187 | + | } | |||
| 188 | + | out = utf8.AppendRune(out, r) | |||
| 189 | + | } | |||
| 190 | + | return string(out) | |||
| 191 | + | } | |||
A internal/module/control_test.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | package module | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestParseControlValid(t *testing.T) { | |
| 10 | + | c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`)) | |
| 11 | + | if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" { | |
| 12 | + | t.Fatalf("tooltip: %+v, %v", c, err) | |
| 13 | + | } | |
| 14 | + | c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[ | |
| 15 | + | {"id":1,"label":"Open"},{"id":2,"kind":"separator"}, | |
| 16 | + | {"id":3,"label":"Mute","kind":"check","checked":true}, | |
| 17 | + | {"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]}, | |
| 18 | + | {"id":6,"label":"Quit","enabled":false}]}`)) | |
| 19 | + | if err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | if c.Click != 9 || len(c.Items) != 5 { | |
| 23 | + | t.Fatalf("menu: %+v", c) | |
| 24 | + | } | |
| 25 | + | if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal { | |
| 26 | + | t.Fatalf("defaults: %+v", c.Items[0]) | |
| 27 | + | } | |
| 28 | + | if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked { | |
| 29 | + | t.Fatalf("kinds: %+v", c.Items) | |
| 30 | + | } | |
| 31 | + | if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled { | |
| 32 | + | t.Fatalf("submenu/disabled: %+v", c.Items) | |
| 33 | + | } | |
| 34 | + | if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose { | |
| 35 | + | t.Fatalf("close: %+v, %v", c, err) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // nested builds a menu with depth levels of submenus. | |
| 40 | + | func nested(depth int) string { | |
| 41 | + | s := `{"id":1,"label":"x"}` | |
| 42 | + | for i := 1; i < depth; i++ { | |
| 43 | + | s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s) | |
| 44 | + | } | |
| 45 | + | return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestParseControlLimits(t *testing.T) { | |
| 49 | + | if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil { | |
| 50 | + | t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err) | |
| 51 | + | } | |
| 52 | + | many := func(n int) string { | |
| 53 | + | return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}` | |
| 54 | + | } | |
| 55 | + | if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil { | |
| 56 | + | t.Fatalf("%d items must pass: %v", MaxMenuItems, err) | |
| 57 | + | } | |
| 58 | + | for name, in := range map[string]string{ | |
| 59 | + | "too deep": nested(MaxMenuDepth + 1), | |
| 60 | + | "too many": many(MaxMenuItems + 1), | |
| 61 | + | "oversize": `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`, | |
| 62 | + | "unknown type": `{"type":"explode"}`, | |
| 63 | + | "no type": `{}`, | |
| 64 | + | "bad json": `{"type":`, | |
| 65 | + | "not an object": `[1]`, | |
| 66 | + | "neg col": `{"type":"tooltip","col":-1,"width":1,"title":"a"}`, | |
| 67 | + | "huge width": `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`, | |
| 68 | + | "zero width": `{"type":"tooltip","col":0,"width":0,"title":"a"}`, | |
| 69 | + | "empty tip": `{"type":"tooltip","col":0,"width":1}`, | |
| 70 | + | "bad kind": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`, | |
| 71 | + | "neg id": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`, | |
| 72 | + | "id overflow": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`, | |
| 73 | + | "no items": `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`, | |
| 74 | + | "neg click": `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`, | |
| 75 | + | } { | |
| 76 | + | if _, err := ParseControl([]byte(in)); err == nil { | |
| 77 | + | t.Errorf("%s: want an error", name) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestParseControlCleansText(t *testing.T) { | |
| 83 | + | long := strings.Repeat("é", MaxLabel) // 2 bytes each | |
| 84 | + | c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`)) | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | got := c.Items[0].Label | |
| 89 | + | if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") { | |
| 90 | + | t.Fatalf("label = %q (%d bytes)", got, len(got)) | |
| 91 | + | } | |
| 92 | + | if strings.ContainsRune(got, '\uFFFD') { | |
| 93 | + | t.Fatalf("cut inside a rune: %q", got) | |
| 94 | + | } | |
| 95 | + | c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`)) | |
| 96 | + | if err != nil || c.Title != "T" || c.Body != "a\nbc" { | |
| 97 | + | t.Fatalf("tooltip text: %+v, %v", c, err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) { | |
| 102 | + | m, _ := newTest() | |
| 103 | + | m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n")) | |
| 104 | + | got, changed, _ := m.Take(nil) | |
| 105 | + | if !changed || string(got) != "two" { | |
| 106 | + | t.Fatalf("frame = %q changed=%v, want two", got, changed) | |
| 107 | + | } | |
| 108 | + | b, ok := m.TakeControl() | |
| 109 | + | if !ok || string(b) != `{"type":"close"}` { | |
| 110 | + | t.Fatalf("control = %q %v", b, ok) | |
| 111 | + | } | |
| 112 | + | if _, ok = m.TakeControl(); ok { | |
| 113 | + | t.Fatal("one control line expected") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // The control line alone never becomes a frame. | |
| 117 | + | m, _ = newTest() | |
| 118 | + | m.readFrames(strings.NewReader("one\n")) | |
| 119 | + | m.Take(nil) | |
| 120 | + | m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")) | |
| 121 | + | if _, changed, _ = m.Take(nil); changed { | |
| 122 | + | t.Fatal("a control line must not be published as a frame") | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestUnterminatedControlIsAFrame(t *testing.T) { | |
| 127 | + | m, _ := newTest() | |
| 128 | + | m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n")) | |
| 129 | + | if _, ok := m.TakeControl(); ok { | |
| 130 | + | t.Fatal("a line without BEL is not a control line") | |
| 131 | + | } | |
| 132 | + | got, changed, _ := m.Take(nil) | |
| 133 | + | if !changed || string(got) != "\x1b]777;neferbar;{bad" { | |
| 134 | + | t.Fatalf("frame = %q changed=%v", got, changed) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestOversizeControlIsDropped(t *testing.T) { | |
| 139 | + | m, _ := newTest() | |
| 140 | + | m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n")) | |
| 141 | + | if _, ok := m.TakeControl(); ok { | |
| 142 | + | t.Fatal("an oversize control line must be dropped") | |
| 143 | + | } | |
| 144 | + | if got, _, _ := m.Take(nil); string(got) != "b" { | |
| 145 | + | t.Fatalf("frame = %q, want b", got) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestControlQueueDropsWhenFull(t *testing.T) { | |
| 150 | + | m, wake := newTest() | |
| 151 | + | var in strings.Builder | |
| 152 | + | for range 20 { | |
| 153 | + | in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n") | |
| 154 | + | } | |
| 155 | + | m.readFrames(strings.NewReader(in.String())) | |
| 156 | + | n := 0 | |
| 157 | + | for { | |
| 158 | + | if _, ok := m.TakeControl(); !ok { | |
| 159 | + | break | |
| 160 | + | } | |
| 161 | + | n++ | |
| 162 | + | } | |
| 163 | + | if n != 8 || len(wake) != 1 { | |
| 164 | + | t.Fatalf("queued %d (want 8), wake %d", n, len(wake)) | |
| 165 | + | } | |
| 166 | + | } | |
| @@ -0,0 +1,166 @@ | |||||
| 1 | + | package module | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestParseControlValid(t *testing.T) { | |||
| 10 | + | c, err := ParseControl([]byte(`{"type":"tooltip","col":2,"width":1,"title":"Steam","body":"line1\nline2"}`)) | |||
| 11 | + | if err != nil || c.Type != ControlTooltip || c.Col != 2 || c.Width != 1 || c.Title != "Steam" || c.Body != "line1\nline2" { | |||
| 12 | + | t.Fatalf("tooltip: %+v, %v", c, err) | |||
| 13 | + | } | |||
| 14 | + | c, err = ParseControl([]byte(`{"type":"menu","col":0,"width":2,"click":9,"items":[ | |||
| 15 | + | {"id":1,"label":"Open"},{"id":2,"kind":"separator"}, | |||
| 16 | + | {"id":3,"label":"Mute","kind":"check","checked":true}, | |||
| 17 | + | {"id":4,"label":"Settings","items":[{"id":5,"label":"Advanced"}]}, | |||
| 18 | + | {"id":6,"label":"Quit","enabled":false}]}`)) | |||
| 19 | + | if err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | if c.Click != 9 || len(c.Items) != 5 { | |||
| 23 | + | t.Fatalf("menu: %+v", c) | |||
| 24 | + | } | |||
| 25 | + | if !c.Items[0].Enabled || c.Items[0].Kind != KindNormal { | |||
| 26 | + | t.Fatalf("defaults: %+v", c.Items[0]) | |||
| 27 | + | } | |||
| 28 | + | if c.Items[1].Kind != KindSeparator || c.Items[2].Kind != KindCheck || !c.Items[2].Checked { | |||
| 29 | + | t.Fatalf("kinds: %+v", c.Items) | |||
| 30 | + | } | |||
| 31 | + | if len(c.Items[3].Items) != 1 || c.Items[3].Items[0].ID != 5 || c.Items[4].Enabled { | |||
| 32 | + | t.Fatalf("submenu/disabled: %+v", c.Items) | |||
| 33 | + | } | |||
| 34 | + | if c, err = ParseControl([]byte(`{"type":"close"}`)); err != nil || c.Type != ControlClose { | |||
| 35 | + | t.Fatalf("close: %+v, %v", c, err) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // nested builds a menu with depth levels of submenus. | |||
| 40 | + | func nested(depth int) string { | |||
| 41 | + | s := `{"id":1,"label":"x"}` | |||
| 42 | + | for i := 1; i < depth; i++ { | |||
| 43 | + | s = fmt.Sprintf(`{"id":1,"label":"x","items":[%s]}`, s) | |||
| 44 | + | } | |||
| 45 | + | return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + s + `]}` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestParseControlLimits(t *testing.T) { | |||
| 49 | + | if _, err := ParseControl([]byte(nested(MaxMenuDepth))); err != nil { | |||
| 50 | + | t.Fatalf("depth %d must pass: %v", MaxMenuDepth, err) | |||
| 51 | + | } | |||
| 52 | + | many := func(n int) string { | |||
| 53 | + | return `{"type":"menu","col":0,"width":1,"click":1,"items":[` + strings.TrimSuffix(strings.Repeat(`{"id":1,"label":"a"},`, n), ",") + `]}` | |||
| 54 | + | } | |||
| 55 | + | if _, err := ParseControl([]byte(many(MaxMenuItems))); err != nil { | |||
| 56 | + | t.Fatalf("%d items must pass: %v", MaxMenuItems, err) | |||
| 57 | + | } | |||
| 58 | + | for name, in := range map[string]string{ | |||
| 59 | + | "too deep": nested(MaxMenuDepth + 1), | |||
| 60 | + | "too many": many(MaxMenuItems + 1), | |||
| 61 | + | "oversize": `{"type":"tooltip","col":0,"width":1,"title":"` + strings.Repeat("a", MaxControl) + `"}`, | |||
| 62 | + | "unknown type": `{"type":"explode"}`, | |||
| 63 | + | "no type": `{}`, | |||
| 64 | + | "bad json": `{"type":`, | |||
| 65 | + | "not an object": `[1]`, | |||
| 66 | + | "neg col": `{"type":"tooltip","col":-1,"width":1,"title":"a"}`, | |||
| 67 | + | "huge width": `{"type":"tooltip","col":0,"width":99999999,"title":"a"}`, | |||
| 68 | + | "zero width": `{"type":"tooltip","col":0,"width":0,"title":"a"}`, | |||
| 69 | + | "empty tip": `{"type":"tooltip","col":0,"width":1}`, | |||
| 70 | + | "bad kind": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a","kind":"weird"}]}`, | |||
| 71 | + | "neg id": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":-4,"label":"a"}]}`, | |||
| 72 | + | "id overflow": `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":4294967296,"label":"a"}]}`, | |||
| 73 | + | "no items": `{"type":"menu","col":0,"width":1,"click":1,"items":[]}`, | |||
| 74 | + | "neg click": `{"type":"menu","col":0,"width":1,"click":-1,"items":[{"id":1}]}`, | |||
| 75 | + | } { | |||
| 76 | + | if _, err := ParseControl([]byte(in)); err == nil { | |||
| 77 | + | t.Errorf("%s: want an error", name) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestParseControlCleansText(t *testing.T) { | |||
| 83 | + | long := strings.Repeat("é", MaxLabel) // 2 bytes each | |||
| 84 | + | c, err := ParseControl([]byte(`{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31mb\u0007\n\u202ec` + long + `"}]}`)) | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | got := c.Items[0].Label | |||
| 89 | + | if len(got) > MaxLabel || !strings.HasPrefix(got, "a[31mbc") || strings.ContainsAny(got, "\x1b\x07\n\u202e") { | |||
| 90 | + | t.Fatalf("label = %q (%d bytes)", got, len(got)) | |||
| 91 | + | } | |||
| 92 | + | if strings.ContainsRune(got, '\uFFFD') { | |||
| 93 | + | t.Fatalf("cut inside a rune: %q", got) | |||
| 94 | + | } | |||
| 95 | + | c, err = ParseControl([]byte(`{"type":"tooltip","col":0,"width":1,"title":"T\u001b","body":"a\nb\u0000c"}`)) | |||
| 96 | + | if err != nil || c.Title != "T" || c.Body != "a\nbc" { | |||
| 97 | + | t.Fatalf("tooltip text: %+v, %v", c, err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestControlLineBetweenFramesLeavesBothIntact(t *testing.T) { | |||
| 102 | + | m, _ := newTest() | |||
| 103 | + | m.readFrames(strings.NewReader("one\n\x1b]777;neferbar;{\"type\":\"close\"}\x07\ntwo\n")) | |||
| 104 | + | got, changed, _ := m.Take(nil) | |||
| 105 | + | if !changed || string(got) != "two" { | |||
| 106 | + | t.Fatalf("frame = %q changed=%v, want two", got, changed) | |||
| 107 | + | } | |||
| 108 | + | b, ok := m.TakeControl() | |||
| 109 | + | if !ok || string(b) != `{"type":"close"}` { | |||
| 110 | + | t.Fatalf("control = %q %v", b, ok) | |||
| 111 | + | } | |||
| 112 | + | if _, ok = m.TakeControl(); ok { | |||
| 113 | + | t.Fatal("one control line expected") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // The control line alone never becomes a frame. | |||
| 117 | + | m, _ = newTest() | |||
| 118 | + | m.readFrames(strings.NewReader("one\n")) | |||
| 119 | + | m.Take(nil) | |||
| 120 | + | m.readFrames(strings.NewReader("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n")) | |||
| 121 | + | if _, changed, _ = m.Take(nil); changed { | |||
| 122 | + | t.Fatal("a control line must not be published as a frame") | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func TestUnterminatedControlIsAFrame(t *testing.T) { | |||
| 127 | + | m, _ := newTest() | |||
| 128 | + | m.readFrames(strings.NewReader("\x1b]777;neferbar;{bad\n")) | |||
| 129 | + | if _, ok := m.TakeControl(); ok { | |||
| 130 | + | t.Fatal("a line without BEL is not a control line") | |||
| 131 | + | } | |||
| 132 | + | got, changed, _ := m.Take(nil) | |||
| 133 | + | if !changed || string(got) != "\x1b]777;neferbar;{bad" { | |||
| 134 | + | t.Fatalf("frame = %q changed=%v", got, changed) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestOversizeControlIsDropped(t *testing.T) { | |||
| 139 | + | m, _ := newTest() | |||
| 140 | + | m.readFrames(strings.NewReader("a\n\x1b]777;neferbar;" + strings.Repeat("x", MaxControl) + "\x07\nb\n")) | |||
| 141 | + | if _, ok := m.TakeControl(); ok { | |||
| 142 | + | t.Fatal("an oversize control line must be dropped") | |||
| 143 | + | } | |||
| 144 | + | if got, _, _ := m.Take(nil); string(got) != "b" { | |||
| 145 | + | t.Fatalf("frame = %q, want b", got) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestControlQueueDropsWhenFull(t *testing.T) { | |||
| 150 | + | m, wake := newTest() | |||
| 151 | + | var in strings.Builder | |||
| 152 | + | for range 20 { | |||
| 153 | + | in.WriteString("\x1b]777;neferbar;{\"type\":\"close\"}\x07\n") | |||
| 154 | + | } | |||
| 155 | + | m.readFrames(strings.NewReader(in.String())) | |||
| 156 | + | n := 0 | |||
| 157 | + | for { | |||
| 158 | + | if _, ok := m.TakeControl(); !ok { | |||
| 159 | + | break | |||
| 160 | + | } | |||
| 161 | + | n++ | |||
| 162 | + | } | |||
| 163 | + | if n != 8 || len(wake) != 1 { | |||
| 164 | + | t.Fatalf("queued %d (want 8), wake %d", n, len(wake)) | |||
| 165 | + | } | |||
| 166 | + | } | |||
M internal/module/module.go +67 -14
| @@ -23,7 +23,10 @@ import ( | |||
| 23 | 23 | const ( | |
| 24 | 24 | // MaxFrame bounds one frame; longer lines are truncated. | |
| 25 | 25 | MaxFrame = 16 << 10 | |
| 26 | - | readSize = 32 << 10 | |
| 26 | + | // MaxControl bounds one control line (see ParseControl); longer ones are | |
| 27 | + | // dropped. | |
| 28 | + | MaxControl = 64 << 10 | |
| 29 | + | readSize = 32 << 10 | |
| 27 | 30 | ||
| 28 | 31 | minBackoff = 500 * time.Millisecond | |
| 29 | 32 | maxBackoff = 30 * time.Second | |
| @@ -56,8 +59,9 @@ type Module struct { | |||
| 56 | 59 | dirty bool | |
| 57 | 60 | failed bool // the script is not running; show an error marker | |
| 58 | 61 | ||
| 59 | - | input chan event // lines for the script's stdin | |
| 60 | - | gen atomic.Uint64 // counts script starts; see Gen | |
| 62 | + | input chan event // lines for the script's stdin | |
| 63 | + | control chan []byte // control lines of the script, see TakeControl | |
| 64 | + | gen atomic.Uint64 // counts script starts; see Gen | |
| 61 | 65 | ||
| 62 | 66 | wake chan<- struct{} | |
| 63 | 67 | log *slog.Logger | |
| @@ -77,7 +81,7 @@ type event struct { | |||
| 77 | 81 | // New creates a module. wake receives a non-blocking signal after each frame. | |
| 78 | 82 | func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module { | |
| 79 | 83 | return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log, | |
| 80 | - | pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)} | |
| 84 | + | pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)} | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | // Gen counts the times the script was started. A value that changed means a | |
| @@ -262,22 +266,69 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) { | |||
| 262 | 266 | } | |
| 263 | 267 | } | |
| 264 | 268 | ||
| 265 | - | // readFrames splits r into frames and publishes each one. | |
| 269 | + | // controlPrefix starts a control line: an OSC 777 sequence of the "neferbar" | |
| 270 | + | // application, ended by controlEnd (BEL) before the newline. | |
| 271 | + | const ( | |
| 272 | + | controlPrefix = "\x1b]777;neferbar;" | |
| 273 | + | controlEnd = '\x07' | |
| 274 | + | ) | |
| 275 | + | ||
| 276 | + | // TakeControl returns the oldest control line the script printed, the JSON | |
| 277 | + | // between the prefix and the BEL, or false when there is none. It never blocks. | |
| 278 | + | func (m *Module) TakeControl() ([]byte, bool) { | |
| 279 | + | select { | |
| 280 | + | case b := <-m.control: | |
| 281 | + | return b, true | |
| 282 | + | default: | |
| 283 | + | return nil, false | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // queueControl keeps a copy of the JSON of a control line. A full queue drops | |
| 288 | + | // the line: the bar is not draining it. | |
| 289 | + | func (m *Module) queueControl(json []byte) { | |
| 290 | + | select { | |
| 291 | + | case m.control <- append([]byte(nil), json...): | |
| 292 | + | m.signal() | |
| 293 | + | default: | |
| 294 | + | m.log.Debug("module control queue full; line dropped", "module", m.Name) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // line handles one complete line: a control line, or else a frame. | |
| 299 | + | func (m *Module) line(line []byte, over bool) { | |
| 300 | + | if bytes.HasPrefix(line, []byte(controlPrefix)) { | |
| 301 | + | switch { | |
| 302 | + | case over: | |
| 303 | + | m.log.Warn("module control line too long; dropped", "module", m.Name, "max", MaxControl) | |
| 304 | + | return | |
| 305 | + | case len(line) > 0 && line[len(line)-1] == controlEnd: | |
| 306 | + | m.queueControl(line[len(controlPrefix) : len(line)-1]) | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | // No BEL: not a control line; it is shown like any text. | |
| 310 | + | } | |
| 311 | + | m.publish(line[:min(len(line), MaxFrame)]) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // readFrames splits r into frames and publishes each one. Control lines go to | |
| 315 | + | // the control queue instead. | |
| 266 | 316 | func (m *Module) readFrames(r io.Reader) { | |
| 267 | 317 | buf := make([]byte, readSize) | |
| 268 | - | frame := make([]byte, 0, MaxFrame) | |
| 318 | + | line := make([]byte, 0, MaxFrame) | |
| 319 | + | over := false // the current line is longer than MaxControl | |
| 269 | 320 | for { | |
| 270 | 321 | n, err := r.Read(buf) | |
| 271 | 322 | chunk := buf[:n] | |
| 272 | 323 | for len(chunk) > 0 { | |
| 273 | 324 | i := bytes.IndexAny(chunk, "\n\f") | |
| 274 | 325 | if i < 0 { | |
| 275 | - | frame = appendCapped(frame, chunk) | |
| 326 | + | line, over = appendCapped(line, chunk, over) | |
| 276 | 327 | break | |
| 277 | 328 | } | |
| 278 | - | frame = appendCapped(frame, chunk[:i]) | |
| 279 | - | m.publish(frame) | |
| 280 | - | frame = frame[:0] | |
| 329 | + | line, over = appendCapped(line, chunk[:i], over) | |
| 330 | + | m.line(line, over) | |
| 331 | + | line, over = line[:0], false | |
| 281 | 332 | chunk = chunk[i+1:] | |
| 282 | 333 | } | |
| 283 | 334 | if err != nil { | |
| @@ -286,11 +337,13 @@ func (m *Module) readFrames(r io.Reader) { | |||
| 286 | 337 | } | |
| 287 | 338 | } | |
| 288 | 339 | ||
| 289 | - | func appendCapped(dst, src []byte) []byte { | |
| 290 | - | if room := MaxFrame - len(dst); len(src) > room { | |
| 291 | - | src = src[:max(room, 0)] | |
| 340 | + | // appendCapped appends src to dst up to MaxControl bytes and reports whether | |
| 341 | + | // anything was left out. | |
| 342 | + | func appendCapped(dst, src []byte, over bool) ([]byte, bool) { | |
| 343 | + | if room := MaxControl - len(dst); len(src) > room { | |
| 344 | + | src, over = src[:max(room, 0)], true | |
| 292 | 345 | } | |
| 293 | - | return append(dst, src...) | |
| 346 | + | return append(dst, src...), over | |
| 294 | 347 | } | |
| 295 | 348 | ||
| 296 | 349 | // logWriter forwards a script's stderr lines to the bar's log. | |
| @@ -23,7 +23,10 @@ import ( | |||||
| 23 | const ( | 23 | const ( | ||
| 24 | // MaxFrame bounds one frame; longer lines are truncated. | 24 | // MaxFrame bounds one frame; longer lines are truncated. | ||
| 25 | MaxFrame = 16 << 10 | 25 | MaxFrame = 16 << 10 | ||
| 26 | - | readSize = 32 << 10 | 26 | + | // MaxControl bounds one control line (see ParseControl); longer ones are |
| 27 | + | // dropped. | |||
| 28 | + | MaxControl = 64 << 10 | |||
| 29 | + | readSize = 32 << 10 | |||
| 27 | 30 | ||||
| 28 | minBackoff = 500 * time.Millisecond | 31 | minBackoff = 500 * time.Millisecond | ||
| 29 | maxBackoff = 30 * time.Second | 32 | maxBackoff = 30 * time.Second | ||
| @@ -56,8 +59,9 @@ type Module struct { | |||||
| 56 | dirty bool | 59 | dirty bool | ||
| 57 | failed bool // the script is not running; show an error marker | 60 | failed bool // the script is not running; show an error marker | ||
| 58 | 61 | ||||
| 59 | - | input chan event // lines for the script's stdin | 62 | + | input chan event // lines for the script's stdin |
| 60 | - | gen atomic.Uint64 // counts script starts; see Gen | 63 | + | control chan []byte // control lines of the script, see TakeControl |
| 64 | + | gen atomic.Uint64 // counts script starts; see Gen | |||
| 61 | 65 | ||||
| 62 | wake chan<- struct{} | 66 | wake chan<- struct{} | ||
| 63 | log *slog.Logger | 67 | log *slog.Logger | ||
| @@ -77,7 +81,7 @@ type event struct { | |||||
| 77 | // New creates a module. wake receives a non-blocking signal after each frame. | 81 | // New creates a module. wake receives a non-blocking signal after each frame. | ||
| 78 | func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module { | 82 | func New(name string, zone Zone, command string, wake chan<- struct{}, log *slog.Logger) *Module { | ||
| 79 | return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log, | 83 | return &Module{Name: name, Zone: zone, Exec: command, wake: wake, log: log, | ||
| 80 | - | pending: make([]byte, 0, MaxFrame), input: make(chan event, 32)} | 84 | + | pending: make([]byte, 0, MaxFrame), input: make(chan event, 32), control: make(chan []byte, 8)} |
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
| 83 | // Gen counts the times the script was started. A value that changed means a | 87 | // Gen counts the times the script was started. A value that changed means a | ||
| @@ -262,22 +266,69 @@ func (m *Module) pumpInput(w io.WriteCloser) (stop func()) { | |||||
| 262 | } | 266 | } | ||
| 263 | } | 267 | } | ||
| 264 | 268 | ||||
| 265 | - | // readFrames splits r into frames and publishes each one. | 269 | + | // controlPrefix starts a control line: an OSC 777 sequence of the "neferbar" |
| 270 | + | // application, ended by controlEnd (BEL) before the newline. | |||
| 271 | + | const ( | |||
| 272 | + | controlPrefix = "\x1b]777;neferbar;" | |||
| 273 | + | controlEnd = '\x07' | |||
| 274 | + | ) | |||
| 275 | + | ||||
| 276 | + | // TakeControl returns the oldest control line the script printed, the JSON | |||
| 277 | + | // between the prefix and the BEL, or false when there is none. It never blocks. | |||
| 278 | + | func (m *Module) TakeControl() ([]byte, bool) { | |||
| 279 | + | select { | |||
| 280 | + | case b := <-m.control: | |||
| 281 | + | return b, true | |||
| 282 | + | default: | |||
| 283 | + | return nil, false | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // queueControl keeps a copy of the JSON of a control line. A full queue drops | |||
| 288 | + | // the line: the bar is not draining it. | |||
| 289 | + | func (m *Module) queueControl(json []byte) { | |||
| 290 | + | select { | |||
| 291 | + | case m.control <- append([]byte(nil), json...): | |||
| 292 | + | m.signal() | |||
| 293 | + | default: | |||
| 294 | + | m.log.Debug("module control queue full; line dropped", "module", m.Name) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // line handles one complete line: a control line, or else a frame. | |||
| 299 | + | func (m *Module) line(line []byte, over bool) { | |||
| 300 | + | if bytes.HasPrefix(line, []byte(controlPrefix)) { | |||
| 301 | + | switch { | |||
| 302 | + | case over: | |||
| 303 | + | m.log.Warn("module control line too long; dropped", "module", m.Name, "max", MaxControl) | |||
| 304 | + | return | |||
| 305 | + | case len(line) > 0 && line[len(line)-1] == controlEnd: | |||
| 306 | + | m.queueControl(line[len(controlPrefix) : len(line)-1]) | |||
| 307 | + | return | |||
| 308 | + | } | |||
| 309 | + | // No BEL: not a control line; it is shown like any text. | |||
| 310 | + | } | |||
| 311 | + | m.publish(line[:min(len(line), MaxFrame)]) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // readFrames splits r into frames and publishes each one. Control lines go to | |||
| 315 | + | // the control queue instead. | |||
| 266 | func (m *Module) readFrames(r io.Reader) { | 316 | func (m *Module) readFrames(r io.Reader) { | ||
| 267 | buf := make([]byte, readSize) | 317 | buf := make([]byte, readSize) | ||
| 268 | - | frame := make([]byte, 0, MaxFrame) | 318 | + | line := make([]byte, 0, MaxFrame) |
| 319 | + | over := false // the current line is longer than MaxControl | |||
| 269 | for { | 320 | for { | ||
| 270 | n, err := r.Read(buf) | 321 | n, err := r.Read(buf) | ||
| 271 | chunk := buf[:n] | 322 | chunk := buf[:n] | ||
| 272 | for len(chunk) > 0 { | 323 | for len(chunk) > 0 { | ||
| 273 | i := bytes.IndexAny(chunk, "\n\f") | 324 | i := bytes.IndexAny(chunk, "\n\f") | ||
| 274 | if i < 0 { | 325 | if i < 0 { | ||
| 275 | - | frame = appendCapped(frame, chunk) | 326 | + | line, over = appendCapped(line, chunk, over) |
| 276 | break | 327 | break | ||
| 277 | } | 328 | } | ||
| 278 | - | frame = appendCapped(frame, chunk[:i]) | 329 | + | line, over = appendCapped(line, chunk[:i], over) |
| 279 | - | m.publish(frame) | 330 | + | m.line(line, over) |
| 280 | - | frame = frame[:0] | 331 | + | line, over = line[:0], false |
| 281 | chunk = chunk[i+1:] | 332 | chunk = chunk[i+1:] | ||
| 282 | } | 333 | } | ||
| 283 | if err != nil { | 334 | if err != nil { | ||
| @@ -286,11 +337,13 @@ func (m *Module) readFrames(r io.Reader) { | |||||
| 286 | } | 337 | } | ||
| 287 | } | 338 | } | ||
| 288 | 339 | ||||
| 289 | - | func appendCapped(dst, src []byte) []byte { | 340 | + | // appendCapped appends src to dst up to MaxControl bytes and reports whether |
| 290 | - | if room := MaxFrame - len(dst); len(src) > room { | 341 | + | // anything was left out. |
| 291 | - | src = src[:max(room, 0)] | 342 | + | func appendCapped(dst, src []byte, over bool) ([]byte, bool) { |
| 343 | + | if room := MaxControl - len(dst); len(src) > room { | |||
| 344 | + | src, over = src[:max(room, 0)], true | |||
| 292 | } | 345 | } | ||
| 293 | - | return append(dst, src...) | 346 | + | return append(dst, src...), over |
| 294 | } | 347 | } | ||
| 295 | 348 | ||||
| 296 | // logWriter forwards a script's stderr lines to the bar's log. | 349 | // logWriter forwards a script's stderr lines to the bar's log. | ||