1// Package tray shows the system tray (StatusNotifierItem) as one line of
2// Nerd Font glyphs, for "neferbar tray".
3//
4// It acts as the StatusNotifierWatcher when no other program does, and as a
5// plain host of the existing watcher otherwise, so it can run next to another
6// panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage;
7// calls to applications never block it, as their replies are matched by
8// serial when they arrive.
9package tray
10
11import (
12 "bytes"
13 "context"
14 "errors"
15 "fmt"
16 "io"
17 "log/slog"
18 "os"
19 "slices"
20 "strconv"
21 "strings"
22 "sync"
23
24 vt "github.com/bnema/vev-vt"
25 "github.com/bnema/zerobus"
26)
27
28const (
29 watcherName = "org.kde.StatusNotifierWatcher"
30 watcherPath = "/StatusNotifierWatcher"
31 watcherIface = "org.kde.StatusNotifierWatcher"
32 itemIface = "org.kde.StatusNotifierItem"
33 itemPath = "/StatusNotifierItem"
34 propsIface = "org.freedesktop.DBus.Properties"
35 busName = "org.freedesktop.DBus"
36 busPath = "/org/freedesktop/DBus"
37)
38
39// RGB is a color.
40type RGB = [3]uint8
41
42// Options configure a Tray.
43type Options struct {
44 Resolver *Resolver
45 // Foreground colors items without an icon to take a color from;
46 // Background is what tinted icons must stand out against; Accent marks
47 // items that need attention.
48 Foreground, Background, Accent RGB
49 Out io.Writer // one line per change
50 Log *slog.Logger
51 // In carries the bar's pointer lines (see the README); nil ignores input.
52 In io.Reader
53 // Dial opens the connection that runs the actions In asks for, apart from
54 // the one that serves the tray. Required with In.
55 Dial func() (*zerobus.Conn, error)
56}
57
58type status uint8
59
60const (
61 active status = iota
62 passive
63 attention
64)
65
66type item struct {
67 service string // as listed by the watcher: "bus/path" or "bus"
68 dest string // where to send calls: the owner, or the registered bus name
69 owner string // the unique name; signals come from it
70 path string
71
72 id, iconName, title string
73 status status
74 icon string // resolved glyph; "" hides the item
75 resolved bool
76 color RGB
77 hasColor bool
78 menu string // object path of its dbusmenu, "" when none
79 tipTitle, tipBody string // ToolTip, raw (may hold markup); the title if the item has no tooltip
80 isMenu bool // ItemIsMenu: Activate should open the menu
81 loaded bool // GetAll answered at least once
82 pending uint32 // serial of a GetAll in flight
83 stale bool // a change arrived while GetAll was in flight
84}
85
86type pendKind uint8
87
88const (
89 pendGetAll pendKind = iota + 1
90 pendItems // the watcher's RegisteredStatusNotifierItems
91 pendName // RequestName of the watcher name
92 pendIgnore
93)
94
95type pend struct {
96 serial uint32
97 kind pendKind
98 it *item
99}
100
101// Tray is one running tray.
102type Tray struct {
103 c *zerobus.Conn
104 opt Options
105 watcher bool // this process owns the watcher name
106 host string
107 items []*item
108 pending []pend
109 frame []byte
110 last []byte
111 hist histogram
112 err error // first fatal error from dispatch
113
114 snap snapshot // what the last line printed holds, for the control loop
115 targets []Target // scratch for the next snapshot
116}
117
118// Run serves the tray on c until ctx ends or the bus connection fails. It
119// closes c.
120func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
121 t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()),
122 frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
123 if t.opt.Log == nil {
124 t.opt.Log = slog.New(slog.DiscardHandler)
125 }
126 stop := context.AfterFunc(ctx, func() { _ = c.Close() })
127 defer stop()
128 defer func() { _ = c.Close() }()
129
130 if opt.In != nil {
131 if opt.Dial == nil {
132 return fmt.Errorf("tray: Options.In needs Options.Dial")
133 }
134 t.opt.Out = &lockedWriter{w: opt.Out}
135 cctx, cancel := context.WithCancel(ctx)
136 lines := make(chan string, 16)
137 ctl := &control{t: t, dial: opt.Dial, log: t.opt.Log}
138 var wg sync.WaitGroup
139 wg.Add(1)
140 go func() { defer wg.Done(); ctl.run(cctx, lines) }()
141 // The reader may sit in a blocking Read of stdin that nothing can
142 // interrupt; it is not waited for, and ends with the process.
143 go readCommands(cctx, opt.In, lines, t.opt.Log)
144 defer func() { cancel(); wg.Wait() }()
145 }
146
147 // Calls made from here on only go to the bus, which always answers.
148 // Handle sees what arrives meanwhile.
149 c.Handle = t.dispatch
150 for _, rule := range [...]string{
151 "type='signal',interface='" + itemIface + "'",
152 // The bus matches a well-known sender by its current owner.
153 "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
154 "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
155 } {
156 if err := c.AddMatch(rule); err != nil {
157 return t.exit(ctx, fmt.Errorf("tray: add match: %w", err))
158 }
159 }
160 if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil {
161 return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err))
162 }
163 t.claim()
164 if err := t.render(true); err != nil {
165 return t.exit(ctx, err)
166 }
167 for t.err == nil {
168 m, err := c.ReadMessage()
169 if err != nil {
170 return t.exit(ctx, err)
171 }
172 t.dispatch(m)
173 }
174 // A send fails too when ctx ends during dispatch and closes c.
175 return t.exit(ctx, t.err)
176}
177
178// exit returns nil when the error comes from ctx ending.
179func (t *Tray) exit(ctx context.Context, err error) error {
180 if ctx.Err() != nil {
181 return nil
182 }
183 return err
184}
185
186func (t *Tray) send() uint32 {
187 serial, err := t.c.Send()
188 if err != nil {
189 if errors.Is(err, zerobus.ErrInvalid) {
190 // A name or path an application gave us that D-Bus refuses.
191 t.opt.Log.Warn("tray: message not sent", "err", err)
192 return 0
193 }
194 t.fatal(err)
195 return 0
196 }
197 return serial
198}
199
200func (t *Tray) fatal(err error) {
201 if t.err == nil {
202 t.err = err
203 }
204}
205
206func (t *Tray) expect(serial uint32, kind pendKind, it *item) {
207 if serial != 0 {
208 t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it})
209 }
210}
211
212// claim asks for the watcher name; the reply decides the mode.
213func (t *Tray) claim() {
214 e := t.c.NewCall(busName, busPath, busName, "RequestName", "su")
215 e.Str(watcherName)
216 e.Uint32(zerobus.NameFlagDoNotQueue)
217 t.expect(t.send(), pendName, nil)
218}
219
220func (t *Tray) dispatch(m *zerobus.Message) {
221 switch m.Type {
222 case zerobus.TypeMethodReturn, zerobus.TypeError:
223 i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial })
224 if i < 0 {
225 return
226 }
227 p := t.pending[i]
228 t.pending = slices.Delete(t.pending, i, i+1)
229 t.reply(p, m)
230 case zerobus.TypeSignal:
231 t.signal(m)
232 case zerobus.TypeMethodCall:
233 t.call(m)
234 }
235}
236
237func (t *Tray) reply(p pend, m *zerobus.Message) {
238 failed := m.Type == zerobus.TypeError
239 switch p.kind {
240 case pendName:
241 code := m.Body().Uint32()
242 if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
243 t.becomeWatcher()
244 return
245 }
246 t.becomeHost()
247 case pendItems:
248 if failed {
249 t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName)
250 return
251 }
252 r := m.Body()
253 if m.Signature != "v" || r.Variant() != "as" {
254 t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
255 return
256 }
257 end := r.Array('s')
258 for r.More(end) {
259 t.add(r.Str(), "")
260 }
261 case pendGetAll:
262 it := p.it
263 if it.pending != m.ReplySerial || !slices.Contains(t.items, it) {
264 return // removed, or an older request
265 }
266 it.pending = 0
267 if failed || m.Signature != "a{sv}" {
268 t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
269 if !it.loaded {
270 t.remove(it)
271 }
272 return
273 }
274 if it.owner != m.Sender {
275 it.owner = strings.Clone(m.Sender)
276 }
277 t.load(it, m.Body())
278 if it.stale {
279 t.refresh(it)
280 }
281 }
282}
283
284func (t *Tray) becomeWatcher() {
285 t.watcher = true
286 t.opt.Log.Info("tray: acting as the StatusNotifierWatcher")
287 // Items that waited for a host register now.
288 t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "")
289 t.send()
290}
291
292func (t *Tray) becomeHost() {
293 t.watcher = false
294 t.opt.Log.Info("tray: using the existing StatusNotifierWatcher")
295 t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host)
296 t.expect(t.send(), pendIgnore, nil)
297 e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
298 e.Str(watcherIface)
299 e.Str("RegisteredStatusNotifierItems")
300 t.expect(t.send(), pendItems, nil)
301}
302
303func (t *Tray) signal(m *zerobus.Message) {
304 switch m.Interface {
305 case busName:
306 if m.Member != "NameOwnerChanged" || m.Sender != busName {
307 return
308 }
309 r := m.Body()
310 name, old, owner := r.Str(), r.Str(), r.Str()
311 switch {
312 case r.Err() != nil:
313 case owner == "":
314 t.vanished(name)
315 case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
316 // Another watcher replaced the one we used: its items are
317 // the ones to show now, not a late list from the old one.
318 t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems })
319 t.dropAll()
320 t.becomeHost()
321 }
322 case watcherIface:
323 if t.watcher || m.Signature != "s" {
324 return // our own signals come back to us too
325 }
326 s := m.Body().Str()
327 switch m.Member {
328 case "StatusNotifierItemRegistered":
329 t.add(s, "")
330 case "StatusNotifierItemUnregistered":
331 svc, _, _ := normService(s)
332 if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
333 t.remove(t.items[i])
334 }
335 }
336 case itemIface:
337 switch m.Member {
338 case "NewMenu", "NewOverlayIcon":
339 return // nothing the tray shows
340 }
341 for _, it := range t.items {
342 if it.owner == m.Sender && it.path == m.Path {
343 t.refresh(it)
344 return
345 }
346 }
347 }
348}
349
350// vanished drops the items of a bus name that left, and takes over the
351// watcher name when its owner left.
352func (t *Tray) vanished(name string) {
353 changed := false
354 for i := len(t.items) - 1; i >= 0; i-- {
355 if it := t.items[i]; it.owner == name || it.dest == name {
356 t.drop(i)
357 changed = true
358 }
359 }
360 if changed {
361 t.renderOrFail()
362 }
363 if name == watcherName && !t.watcher {
364 // Items register again with the new watcher.
365 t.dropAll()
366 t.claim()
367 }
368}
369
370func (t *Tray) dropAll() {
371 if len(t.items) == 0 {
372 return
373 }
374 for i := len(t.items) - 1; i >= 0; i-- {
375 t.drop(i)
376 }
377 t.renderOrFail()
378}
379
380// normService turns the watcher's form of an item, "bus/path" or "bus", into
381// "bus/path", its bus and its path.
382func normService(s string) (service, bus, path string) {
383 bus, p, ok := strings.Cut(s, "/")
384 if !ok {
385 return bus + itemPath, bus, itemPath
386 }
387 return s, bus, "/" + p
388}
389
390// validBusName reports whether s is a unique (":1.42") or well-known
391// ("org.example.App") bus name, as D-Bus defines them.
392func validBusName(s string) bool {
393 if s == "" || len(s) > 255 {
394 return false
395 }
396 unique := s[0] == ':'
397 if unique {
398 s = s[1:]
399 }
400 n := 0
401 for more := true; more; n++ {
402 var e string
403 e, s, more = strings.Cut(s, ".")
404 if e == "" || !unique && e[0] >= '0' && e[0] <= '9' {
405 return false
406 }
407 for _, c := range []byte(e) {
408 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
409 return false
410 }
411 }
412 }
413 return n >= 2
414}
415
416// addResult says what add did.
417type addResult uint8
418
419const (
420 addNew addResult = iota
421 addKnown
422 addInvalid // D-Bus refuses its bus name or path
423)
424
425// add starts tracking an item. service is the watcher's form, "bus/path" or
426// "bus"; owner is its unique name when known.
427func (t *Tray) add(service, owner string) addResult {
428 service, dest, path := normService(service)
429 if owner == "" && strings.HasPrefix(dest, ":") {
430 owner = dest
431 }
432 for _, it := range t.items {
433 if it.service == service || owner != "" && it.owner == owner && it.path == path {
434 return addKnown
435 }
436 }
437 it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
438 t.items = append(t.items, it)
439 t.refresh(it)
440 if it.pending == 0 {
441 // Never shown nor announced: forget it quietly.
442 t.items = t.items[:len(t.items)-1]
443 return addInvalid
444 }
445 return addNew
446}
447
448func (t *Tray) remove(it *item) {
449 if i := slices.Index(t.items, it); i >= 0 {
450 t.drop(i)
451 t.renderOrFail()
452 }
453}
454
455func (t *Tray) drop(i int) {
456 it := t.items[i]
457 t.items = slices.Delete(t.items, i, i+1)
458 if t.watcher {
459 t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service)
460 t.send()
461 }
462}
463
464// refresh asks an item for its properties, unless a request is in flight.
465func (t *Tray) refresh(it *item) {
466 if it.pending != 0 {
467 it.stale = true
468 return
469 }
470 it.stale = false
471 dest := it.dest
472 if it.owner != "" {
473 dest = it.owner
474 }
475 t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface)
476 it.pending = t.send()
477 t.expect(it.pending, pendGetAll, it)
478}
479
480// load reads a GetAll reply into it and redraws when something shown
481// changed. Strings are copied only when they change.
482func (t *Tray) load(it *item, r *zerobus.Reader) {
483 was := *it
484 var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind
485 tipSeen := false
486 end := r.Array('{')
487 for r.More(end) {
488 r.Struct()
489 key := r.Str()
490 sig := r.Variant()
491 switch {
492 case sig == "s" && key == "Id":
493 setStr(&it.id, r.Str())
494 case sig == "s" && key == "Title":
495 setStr(&it.title, r.Str())
496 case sig == "s" && key == "IconName":
497 setStr(&it.iconName, r.Str())
498 case sig == "o" && key == "Menu":
499 setStr(&it.menu, r.ObjectPath())
500 case sig == "(sa(iiay)ss)" && key == "ToolTip":
501 r.Struct()
502 r.Str() // icon name
503 r.Skip("a(iiay)") // pixmaps
504 setStr(&it.tipTitle, r.Str())
505 setStr(&it.tipBody, r.Str())
506 tipSeen = true
507 case sig == "b" && key == "ItemIsMenu":
508 it.isMenu = r.Bool()
509 case sig == "s" && key == "Status":
510 it.status = parseStatus(r.Str())
511 case sig == "a(iiay)" && key == "IconPixmap":
512 pixmaps = pickPixmap(r)
513 case sig == "a(iiay)" && key == "AttentionIconPixmap":
514 attentionPixmaps = pickPixmap(r)
515 default:
516 r.Skip(sig)
517 }
518 }
519 if r.Err() != nil {
520 t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err())
521 *it = was
522 return
523 }
524 if !tipSeen {
525 it.tipTitle, it.tipBody = "", ""
526 }
527 it.loaded = true
528 if it.status == attention && attentionPixmaps != nil {
529 pixmaps = attentionPixmaps
530 }
531 it.color, it.hasColor = t.hist.dominant(pixmaps)
532 if it.hasColor {
533 it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
534 }
535 if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
536 it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
537 }
538 t.renderOrFail()
539}
540
541// pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap
542// best suited to find a color: the largest up to 64x64, else the smallest.
543// The result points into the message buffer.
544func pickPixmap(r *zerobus.Reader) []byte {
545 var best []byte
546 bestN := 0
547 end := r.Array('(')
548 for r.More(end) {
549 r.Struct()
550 w, h := int(r.Int32()), int(r.Int32())
551 data := r.ByteArray()
552 n := w * h
553 if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n {
554 continue
555 }
556 if best == nil || better(n, bestN) {
557 best, bestN = data, n
558 }
559 }
560 return best
561}
562
563// better reports whether an icon of n pixels suits the color search better
564// than one of cur pixels: the largest up to 64x64, else the smallest.
565func better(n, cur int) bool {
566 const enough = 64 * 64
567 if cur > enough {
568 return n < cur
569 }
570 return n > cur && n <= enough
571}
572
573func setStr(dst *string, s string) {
574 if *dst != s {
575 *dst = strings.Clone(s)
576 }
577}
578
579func parseStatus(s string) status {
580 switch s {
581 case "Passive":
582 return passive
583 case "NeedsAttention":
584 return attention
585 }
586 return active
587}
588
589// call answers method calls when this process is the watcher.
590func (t *Tray) call(m *zerobus.Message) {
591 noReply := m.Flags&zerobus.FlagNoReplyExpected != 0
592 if !t.watcher || m.Path != watcherPath {
593 if !noReply {
594 t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object")
595 t.send()
596 }
597 return
598 }
599 switch {
600 case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s":
601 s := m.Body().Str()
602 if s == "" {
603 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
604 return
605 }
606 if strings.HasPrefix(s, "/") {
607 s = m.Sender + s // registered by object path
608 } else if validBusName(s) {
609 s += itemPath
610 } else {
611 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
612 return
613 }
614 // add copies s before the next read, and sends only a GetAll.
615 switch t.add(s, m.Sender) {
616 case addInvalid:
617 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
618 return
619 case addNew:
620 t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
621 t.send()
622 }
623 if !noReply {
624 t.c.NewReply(m, "")
625 t.send()
626 }
627 case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
628 if !noReply {
629 t.c.NewReply(m, "")
630 t.send()
631 }
632 case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss":
633 r := m.Body()
634 iface, prop := r.Str(), r.Str()
635 if iface != watcherIface || !t.prop(m, prop, noReply) {
636 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property")
637 }
638 case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s":
639 if noReply {
640 return
641 }
642 e := t.c.NewReply(m, "a{sv}")
643 a := e.BeginArray('{')
644 if m.Body().Str() == watcherIface {
645 for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} {
646 e.Struct()
647 e.Str(p)
648 t.propValue(e, p)
649 }
650 }
651 e.EndArray(a)
652 t.send()
653 case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect":
654 if !noReply {
655 t.c.NewReply(m, "s").Str(introspection)
656 t.send()
657 }
658 case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping":
659 if !noReply {
660 t.c.NewReply(m, "")
661 t.send()
662 }
663 default:
664 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method")
665 }
666}
667
668func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) {
669 if !noReply {
670 t.c.NewError(m, name, "s").Str(text)
671 t.send()
672 }
673}
674
675// prop answers Properties.Get; it reports false for an unknown property.
676func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool {
677 switch name {
678 case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion":
679 default:
680 return false
681 }
682 if !noReply {
683 t.propValue(t.c.NewReply(m, "v"), name)
684 t.send()
685 }
686 return true
687}
688
689func (t *Tray) propValue(e *zerobus.Encoder, name string) {
690 switch name {
691 case "RegisteredStatusNotifierItems":
692 e.Variant("as")
693 a := e.BeginArray('s')
694 for _, it := range t.items {
695 e.Str(it.service)
696 }
697 e.EndArray(a)
698 case "IsStatusNotifierHostRegistered":
699 e.Variant("b")
700 e.Bool(true) // this process is the host
701 case "ProtocolVersion":
702 e.Variant("i")
703 e.Int32(0)
704 }
705}
706
707func (t *Tray) renderOrFail() {
708 if err := t.render(false); err != nil {
709 t.fatal(err)
710 }
711}
712
713// render writes the line of visible items when it differs from the last one,
714// and publishes where each icon sits for the control loop. The targets are
715// compared too: an item may change its menu without changing its icon.
716func (t *Tray) render(force bool) error {
717 f := t.frame[:0]
718 ts := t.targets[:0]
719 col := 0
720 for _, it := range t.items {
721 if !it.loaded || it.status == passive || it.icon == "" {
722 continue
723 }
724 if len(f) > 0 {
725 f = append(f, ' ')
726 col++
727 }
728 c, bold := t.opt.Foreground, false
729 switch {
730 case it.status == attention:
731 c, bold = t.opt.Accent, true
732 case it.hasColor:
733 c = it.color
734 }
735 f = append(f, "\x1b["...)
736 if bold {
737 f = append(f, "1;"...)
738 }
739 f = append(f, "38;2;"...)
740 f = strconv.AppendUint(f, uint64(c[0]), 10)
741 f = append(f, ';')
742 f = strconv.AppendUint(f, uint64(c[1]), 10)
743 f = append(f, ';')
744 f = strconv.AppendUint(f, uint64(c[2]), 10)
745 f = append(f, 'm')
746 f = append(f, it.icon...)
747 f = append(f, "\x1b[0m"...)
748 dest := it.dest
749 if it.owner != "" {
750 dest = it.owner
751 }
752 w := 0
753 for _, r := range it.icon {
754 w += vt.RuneWidth(r)
755 }
756 title, body := it.tipTitle, it.tipBody
757 if title == "" && body == "" {
758 title = it.title
759 }
760 ts = append(ts, Target{Dest: dest, Path: it.path, Menu: it.menu, IsMenu: it.isMenu, Start: col, Width: w,
761 TipTitle: title, TipBody: body})
762 col += w
763 }
764 f = append(f, '\n')
765 t.frame, t.targets = f, ts
766 sameFrame := bytes.Equal(f, t.last)
767 if !force && sameFrame && t.sameTargets(ts) {
768 return nil
769 }
770 t.snap.publish(ts) // before the line: whoever sees the line finds its targets
771 if !force && sameFrame {
772 return nil
773 }
774 t.frame, t.last = t.last, t.frame
775 if _, err := t.opt.Out.Write(t.last); err != nil {
776 return fmt.Errorf("tray: %w", err)
777 }
778 return nil
779}
780
781// sameTargets reports whether the published snapshot equals ts.
782func (t *Tray) sameTargets(ts []Target) bool {
783 t.snap.mu.Lock()
784 defer t.snap.mu.Unlock()
785 return slices.Equal(t.snap.targets, ts)
786}
787
788const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
789 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
790<node>
791 <interface name="org.kde.StatusNotifierWatcher">
792 <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method>
793 <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method>
794 <property name="RegisteredStatusNotifierItems" type="as" access="read"/>
795 <property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
796 <property name="ProtocolVersion" type="i" access="read"/>
797 <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal>
798 <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal>
799 <signal name="StatusNotifierHostRegistered"/>
800 </interface>
801 <interface name="org.freedesktop.DBus.Properties">
802 <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method>
803 <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method>
804 </interface>
805 <interface name="org.freedesktop.DBus.Introspectable">
806 <method name="Introspect"><arg type="s" direction="out"/></method>
807 </interface>
808</node>
809`