1package tray
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "io"
10 "log/slog"
11 "slices"
12 "strconv"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/bnema/zerobus"
18)
19
20const (
21 // callTimeout is how long the control loop waits for an application. A
22 // frozen one must not keep the next click from working.
23 callTimeout = 2 * time.Second
24 // maxInputLine bounds a line from the bar; the bar sends at most 127 bytes.
25 maxInputLine = 256
26 // maxSteps bounds the steps of a scroll line.
27 maxSteps = 1 << 16
28 // tooltipDelay is how long the pointer rests on an icon before its
29 // tooltip is sent to the bar.
30 tooltipDelay = 500 * time.Millisecond
31)
32
33// Control lines to the bar: OSC 777 "neferbar", a JSON object, BEL, newline.
34const (
35 controlPrefix = "\x1b]777;neferbar;"
36 controlSuffix = "\x07\n"
37)
38
39// lockedWriter serializes writes from the main loop and the control loop to
40// the shared output.
41type lockedWriter struct {
42 mu sync.Mutex
43 w io.Writer
44}
45
46func (l *lockedWriter) Write(p []byte) (int, error) {
47 l.mu.Lock()
48 defer l.mu.Unlock()
49 return l.w.Write(p)
50}
51
52// button is a mouse button named in a click line.
53type button uint8
54
55const (
56 btnLeft button = iota + 1
57 btnMiddle
58 btnRight
59)
60
61// command is one parsed line from the bar.
62type command struct {
63 verb string // "click", "scroll", "hover", "leave", "menu-activate", "menu-closed"
64 button button // click
65 vert bool // scroll: vertical axis
66 delta int32 // scroll: signed steps, down and right positive
67 col int
68 token uint32 // click: the press token
69}
70
71// parseCommand reads one line of the protocol. Every field must be present,
72// numbers must be plain non-negative decimals, and nothing may follow.
73func parseCommand(line string) (command, bool) {
74 f := strings.Fields(line)
75 if len(f) == 0 {
76 return command{}, false
77 }
78 // Plain decimal digits only: ParseUint takes no sign, and the bit size
79 // keeps the value in range on 32-bit platforms, where int is 32 bits.
80 num := func(s string, bits int, limit uint64) (uint64, bool) {
81 n, err := strconv.ParseUint(s, 10, bits)
82 return n, err == nil && n <= limit
83 }
84 // Columns are bounded by 1<<20; tokens use the full uint32.
85 col := func(s string) (int, bool) { n, ok := num(s, 31, 1<<20); return int(n), ok }
86 c := command{verb: f[0]}
87 switch f[0] {
88 case "click":
89 if len(f) != 4 {
90 return command{}, false
91 }
92 switch f[1] {
93 case "left":
94 c.button = btnLeft
95 case "middle":
96 c.button = btnMiddle
97 case "right":
98 c.button = btnRight
99 default:
100 return command{}, false
101 }
102 cl, ok1 := col(f[2])
103 tok, ok2 := num(f[3], 32, 1<<32-1)
104 if !ok1 || !ok2 {
105 return command{}, false
106 }
107 c.col, c.token = cl, uint32(tok)
108 case "scroll":
109 if len(f) != 4 {
110 return command{}, false
111 }
112 sign := int32(1)
113 switch f[1] {
114 case "down":
115 c.vert = true
116 case "up":
117 c.vert, sign = true, -1
118 case "right":
119 case "left":
120 sign = -1
121 default:
122 return command{}, false
123 }
124 steps, ok1 := num(f[2], 31, maxSteps)
125 cl, ok2 := col(f[3])
126 if !ok1 || !ok2 || steps == 0 {
127 return command{}, false
128 }
129 c.delta, c.col = sign*int32(steps), cl
130 case "hover":
131 cl, ok := col(f[len(f)-1])
132 if len(f) != 2 || !ok {
133 return command{}, false
134 }
135 c.col = cl
136 case "leave":
137 if len(f) != 1 {
138 return command{}, false
139 }
140 case "menu-activate":
141 if len(f) != 3 {
142 return command{}, false
143 }
144 tok, ok1 := num(f[1], 32, 1<<32-1)
145 id, ok2 := num(f[2], 31, 1<<31-1)
146 if !ok1 || !ok2 {
147 return command{}, false
148 }
149 c.token, c.col = uint32(tok), int(id) // col carries the menu item id
150 case "menu-closed":
151 tok, ok := num(f[len(f)-1], 32, 1<<32-1)
152 if len(f) != 2 || !ok {
153 return command{}, false
154 }
155 c.token = uint32(tok)
156 default:
157 return command{}, false
158 }
159 return c, true
160}
161
162// readCommands sends the lines of r to out until r ends or ctx is done. A
163// line longer than maxInputLine is dropped whole.
164func readCommands(ctx context.Context, r io.Reader, out chan<- string, log *slog.Logger) {
165 br := bufio.NewReaderSize(r, maxInputLine)
166 skipping := false
167 for {
168 b, err := br.ReadSlice('\n')
169 switch {
170 case errors.Is(err, bufio.ErrBufferFull):
171 skipping = true // the rest of this line is dropped too
172 continue
173 case err != nil && len(b) == 0:
174 return
175 }
176 if skipping {
177 skipping = false
178 log.Debug("tray: input line too long")
179 } else if err == nil {
180 select {
181 case out <- strings.TrimRight(string(b), "\r\n"):
182 case <-ctx.Done():
183 return
184 }
185 }
186 if err != nil {
187 return
188 }
189 }
190}
191
192// control performs the actions the bar asks for. It owns a second bus
193// connection, so a slow application never holds up the tray's own loop.
194type control struct {
195 t *Tray
196 dial func() (*zerobus.Conn, error)
197 log *slog.Logger
198
199 mu sync.Mutex // guards conn and done: ctx ending closes conn from another goroutine
200 conn *zerobus.Conn
201 done bool
202
203 // The tooltip and menu state belong to the run goroutine.
204 tipTimer *time.Timer
205 hov hover
206 menu *lastMenu
207
208 // deadline, when set, ends every call of the sequence in progress
209 // (openMenu): a call waits for the smaller of callTimeout and what is left.
210 deadline time.Time
211}
212
213// hover is the icon the pointer rests on, and whether its tooltip is shown.
214type hover struct {
215 active bool
216 dest, path string
217 col int
218 shown bool
219}
220
221// run serves lines until ctx ends.
222func (c *control) run(ctx context.Context, lines <-chan string) {
223 stop := context.AfterFunc(ctx, c.close)
224 defer stop()
225 defer c.close()
226 c.tipTimer = time.NewTimer(time.Hour)
227 c.tipTimer.Stop()
228 defer c.tipTimer.Stop()
229 for {
230 select {
231 case <-ctx.Done():
232 return
233 case <-c.tipTimer.C:
234 c.showTooltip()
235 case line := <-lines:
236 cmd, ok := parseCommand(line)
237 if !ok {
238 c.log.Debug("tray: ignoring input line", "line", line)
239 continue
240 }
241 c.handle(cmd)
242 }
243 }
244}
245
246func (c *control) close() {
247 c.mu.Lock()
248 defer c.mu.Unlock()
249 c.done = true
250 if c.conn != nil {
251 _ = c.conn.Close()
252 c.conn = nil
253 }
254}
255
256// conn2 returns the control connection, dialing it when needed. The dial runs
257// outside the lock, so shutdown never waits for it: if shutdown began meanwhile
258// the new connection is closed and ErrClosed is returned.
259func (c *control) conn2() (*zerobus.Conn, error) {
260 c.mu.Lock()
261 if c.done {
262 c.mu.Unlock()
263 return nil, zerobus.ErrClosed
264 }
265 if conn := c.conn; conn != nil {
266 c.mu.Unlock()
267 return conn, nil
268 }
269 c.mu.Unlock()
270
271 conn, err := c.dial()
272 if err != nil {
273 return nil, err
274 }
275 c.mu.Lock()
276 defer c.mu.Unlock()
277 if c.done {
278 _ = conn.Close()
279 return nil, zerobus.ErrClosed
280 }
281 c.conn = conn
282 return conn, nil
283}
284
285// drop forgets conn after a transport error; the next call dials again.
286func (c *control) drop(conn *zerobus.Conn) {
287 c.mu.Lock()
288 defer c.mu.Unlock()
289 _ = conn.Close()
290 if c.conn == conn {
291 c.conn = nil
292 }
293}
294
295func (c *control) handle(cmd command) {
296 switch cmd.verb {
297 case "hover":
298 tg, ok := c.t.target(cmd.col)
299 if !ok { // between icons
300 c.endHover()
301 return
302 }
303 if c.hov.active && c.hov.dest == tg.Dest && c.hov.path == tg.Path {
304 return // still on the same icon
305 }
306 c.endHover()
307 c.hov = hover{active: true, dest: tg.Dest, path: tg.Path, col: cmd.col}
308 c.tipTimer.Reset(tooltipDelay)
309 case "leave":
310 c.endHover()
311 case "click":
312 c.endHover()
313 tg, ok := c.t.target(cmd.col)
314 if !ok {
315 return
316 }
317 switch cmd.button {
318 case btnLeft:
319 if tg.IsMenu {
320 c.openMenu(tg, cmd.token)
321 return
322 }
323 err := c.call(tg, "Activate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
324 if isUnknownMethod(err) {
325 c.openMenu(tg, cmd.token)
326 }
327 case btnMiddle:
328 c.call(tg, "SecondaryActivate", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
329 case btnRight:
330 c.openMenu(tg, cmd.token)
331 }
332 case "menu-activate":
333 c.menuActivate(cmd.token, int32(cmd.col)) // col carries the item id
334 case "menu-closed":
335 c.menuClosed(cmd.token)
336 case "scroll":
337 tg, ok := c.t.target(cmd.col)
338 if !ok {
339 return
340 }
341 orientation := "horizontal"
342 if cmd.vert {
343 orientation = "vertical"
344 }
345 c.call(tg, "Scroll", "is", func(e *zerobus.Encoder) { e.Int32(cmd.delta); e.Str(orientation) })
346 }
347}
348
349// endHover stops the tooltip timer and, if the tooltip was sent, tells the bar
350// to close it.
351func (c *control) endHover() {
352 c.tipTimer.Stop()
353 if c.hov.shown {
354 c.emit(map[string]string{"type": "close"})
355 }
356 c.hov = hover{}
357}
358
359// showTooltip sends the tooltip of the hovered icon when the pointer is still
360// on it and the application has something to say.
361func (c *control) showTooltip() {
362 if !c.hov.active || c.hov.shown {
363 return
364 }
365 tg, ok := c.t.target(c.hov.col)
366 if !ok || tg.Dest != c.hov.dest || tg.Path != c.hov.path {
367 c.endHover()
368 return
369 }
370 title, body := plainText(tg.TipTitle), plainText(tg.TipBody)
371 if title == "" && body == "" {
372 return
373 }
374 c.hov.shown = true
375 c.emit(struct {
376 Type string `json:"type"`
377 Col int `json:"col"`
378 Width int `json:"width"`
379 Title string `json:"title"`
380 Body string `json:"body"`
381 }{"tooltip", tg.Start, tg.Width, title, body})
382}
383
384// emit writes a control line for the bar.
385func (c *control) emit(v any) {
386 var buf bytes.Buffer
387 buf.WriteString(controlPrefix)
388 enc := json.NewEncoder(&buf)
389 enc.SetEscapeHTML(false) // "&" and "<" stay readable; the bar parses JSON either way
390 if err := enc.Encode(v); err != nil {
391 c.log.Warn("tray: cannot encode a control line", "err", err)
392 return
393 }
394 buf.Truncate(buf.Len() - 1) // Encode ends with a newline; the BEL goes first
395 buf.WriteString(controlSuffix)
396 c.write(buf.Bytes())
397}
398
399// writeLine writes the control line whose JSON is b.
400func (c *control) writeLine(b []byte) {
401 line := make([]byte, 0, len(controlPrefix)+len(b)+len(controlSuffix))
402 line = append(append(append(line, controlPrefix...), b...), controlSuffix...)
403 c.write(line)
404}
405
406func (c *control) write(line []byte) {
407 if _, err := c.t.opt.Out.Write(line); err != nil {
408 c.log.Debug("tray: cannot write a control line", "err", err)
409 }
410}
411
412// contextMenu asks the application to show its own menu.
413func (c *control) contextMenu(tg Target) {
414 c.call(tg, "ContextMenu", "ii", func(e *zerobus.Encoder) { e.Int32(0); e.Int32(0) })
415}
416
417func isUnknownMethod(err error) bool {
418 var e *zerobus.Error
419 return errors.As(err, &e) && strings.HasSuffix(e.Name, "UnknownMethod")
420}
421
422// call runs one StatusNotifierItem method with a watchdog. A D-Bus error
423// reply is returned as is; a transport failure or a timeout drops the
424// connection and is logged.
425func (c *control) call(tg Target, member, sig string, args func(*zerobus.Encoder)) error {
426 return c.callOn(tg.Dest, tg.Path, itemIface, member, sig, args, nil)
427}
428
429// callOn is call for any interface. reply, if set, reads the body of a
430// successful reply; it must not keep what it reads.
431func (c *control) callOn(dest, path, iface, member, sig string, args func(*zerobus.Encoder), reply func(*zerobus.Message)) error {
432 conn, err := c.conn2()
433 if err != nil {
434 if !errors.Is(err, zerobus.ErrClosed) {
435 c.log.Warn("tray: cannot reach the session bus", "err", err)
436 }
437 return err
438 }
439 wait := callTimeout
440 if !c.deadline.IsZero() {
441 if wait = min(wait, time.Until(c.deadline)); wait <= 0 {
442 return errBudget
443 }
444 }
445 args(conn.NewCall(dest, path, iface, member, sig))
446 timer := time.AfterFunc(wait, func() { _ = conn.Close() })
447 m, err := conn.Call()
448 if err == nil && reply != nil {
449 reply(m)
450 }
451 expired := !timer.Stop()
452 var refused *zerobus.Error
453 if (errors.As(err, &refused) || errors.Is(err, zerobus.ErrInvalid)) && !expired {
454 // An error reply, or a name or path the library would not send:
455 // the connection is fine.
456 c.log.Debug("tray: the application refused", "method", member, "err", err)
457 return err
458 }
459 if err != nil || expired {
460 c.drop(conn)
461 if err == nil {
462 err = zerobus.ErrClosed
463 }
464 c.mu.Lock()
465 stopping := c.done
466 c.mu.Unlock()
467 if !stopping {
468 c.log.Warn("tray: application did not answer", "method", member, "dest", dest, "err", err)
469 }
470 }
471 return err
472}
473
474// errBudget is returned by a call that did not start because the time for its
475// sequence ran out.
476var errBudget = errors.New("tray: out of time")
477
478// menuBudget is how long opening a menu may take in all, whatever the number
479// of calls it makes. A variable for the tests.
480var menuBudget = 4 * time.Second
481
482// closeBudget is the time given to tell an application its menu will not be
483// shown after all.
484const closeBudget = time.Second
485
486// transportFailed reports whether err means the call did not complete, as
487// opposed to the application answering with an error.
488func transportFailed(err error) bool {
489 var refused *zerobus.Error
490 return err != nil && !errors.As(err, &refused) && !errors.Is(err, zerobus.ErrInvalid)
491}
492
493// openMenu shows the application's menu for the press numbered token: its
494// dbusmenu when it has one, else it asks the application to show its own.
495// The calls share one deadline (menuBudget). When they fail after the
496// application was told "opened", it is told "closed" and the menu forgotten.
497func (c *control) openMenu(tg Target, token uint32) {
498 if tg.Menu == "" || tg.Menu == noMenuPath {
499 c.contextMenu(tg)
500 return
501 }
502 c.menu = nil // an older menu is superseded
503 c.deadline = time.Now().Add(menuBudget)
504 defer func() { c.deadline = time.Time{} }()
505
506 // Ignore refusals: many applications do not implement these.
507 err := c.callOn(tg.Dest, tg.Menu, menuIface, "AboutToShow", "i", func(e *zerobus.Encoder) { e.Int32(0) }, nil)
508 if err != nil && (transportFailed(err) || errors.Is(err, errBudget)) {
509 return
510 }
511 err = c.menuEvent(tg.Dest, tg.Menu, 0, "opened")
512 if errors.Is(err, errBudget) {
513 return // never sent
514 }
515 // From here on the application may believe its menu is open, even if the
516 // call timed out: it may have been delivered.
517 if (err != nil && transportFailed(err)) || !c.sendMenu(tg, token) {
518 c.deadline = time.Now().Add(closeBudget)
519 c.menuEvent(tg.Dest, tg.Menu, 0, "closed")
520 c.menu = nil
521 }
522}
523
524// sendMenu reads the menu of tg and writes it for the bar. It reports false
525// when there is no menu to show.
526func (c *control) sendMenu(tg Target, token uint32) bool {
527 var items []menuItem
528 var decodeErr error
529 err := c.callOn(tg.Dest, tg.Menu, menuIface, "GetLayout", "iias", func(e *zerobus.Encoder) {
530 e.Int32(0)
531 e.Int32(-1)
532 a := e.BeginArray('s')
533 e.EndArray(a)
534 }, func(m *zerobus.Message) {
535 if m.Signature != "u"+layoutSig {
536 decodeErr = errors.New("unexpected layout signature " + m.Signature)
537 return
538 }
539 items, _, decodeErr = decodeLayout(m.Body())
540 })
541 if err != nil {
542 return false
543 }
544 if decodeErr != nil {
545 c.log.Warn("tray: unreadable menu", "dest", tg.Dest, "err", decodeErr)
546 return false
547 }
548 if len(items) == 0 {
549 c.log.Debug("tray: the menu is empty", "dest", tg.Dest)
550 return false
551 }
552 line, ids, err := encodeMenu(menuLine{Type: "menu", Col: tg.Start, Width: tg.Width, Click: token, Items: items})
553 if err != nil {
554 c.log.Warn("tray: cannot send the menu", "dest", tg.Dest, "err", err)
555 return false
556 }
557 c.menu = &lastMenu{token: token, dest: tg.Dest, path: tg.Menu, ids: ids}
558 c.writeLine(line)
559 return true
560}
561
562// menuEvent sends a dbusmenu Event with no data.
563func (c *control) menuEvent(dest, path string, id int32, event string) error {
564 return c.callOn(dest, path, menuIface, "Event", "isvu", func(e *zerobus.Encoder) {
565 e.Int32(id)
566 e.Str(event)
567 e.Variant("i")
568 e.Int32(0)
569 e.Uint32(0)
570 }, nil)
571}
572
573// menuActivate carries out a choice of the bar: only for the menu it was last
574// given, and only for an item that menu holds.
575func (c *control) menuActivate(token uint32, id int32) {
576 m := c.menu
577 if m == nil || m.token != token {
578 c.log.Debug("tray: ignoring a choice for an unknown menu", "token", token)
579 return
580 }
581 if _, found := slices.BinarySearch(m.ids, id); !found {
582 c.log.Debug("tray: ignoring a choice that is not in the menu", "token", token, "id", id)
583 return
584 }
585 c.menu = nil
586 c.menuEvent(m.dest, m.path, id, "clicked")
587}
588
589// menuClosed tells the application its menu is closed.
590func (c *control) menuClosed(token uint32) {
591 m := c.menu
592 if m == nil || m.token != token {
593 return
594 }
595 c.menu = nil
596 c.menuEvent(m.dest, m.path, 0, "closed")
597}