detached e79294b

fix(tray): harden the tray protocol and icon resolution

- Exit cleanly when the context ends during a send, follow a watcher that
  is replaced, normalize bare bus names on unregister, reject item paths
  D-Bus refuses, check reply signatures, accept watcher signals only from
  the watcher's owner, and skip tooltip/menu/overlay signals.
- Match status icons by freedesktop name prefix and check them before
  brands; use titles only as whole names; index only whole names and the
  last part of reverse-DNS names; honor Hidden= and Type=; reload the
  .desktop index when an applications directory changes.
- Sample at most 4096 pixels per icon so color sums cannot overflow, and
  reuse theme.Mix.
- An empty [tray.icons] value hides the item; control characters are
  rejected. Warn about invalid NEFERBAR_* colors.
- Sort glyph names without allocating.
- Test watcher replacement and takeover, registration by path, duplicates,
  invalid paths, bare-name unregister, large icons, the post table and
  resolver precedence; fuzz postNames.
commit
e79294b191dbc0d004d5d55e95a10d6021c55e91
author
brice · · 2026-10-08 20:05 UTC
parent
770277a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +562 -196
M README.md +8 -6
@@ -272,19 +272,21 @@ exec = '"$NEFERBAR_BIN" tray'
272272
273273 Each icon is chosen without a list to maintain, from what the system already knows:
274274
275-1. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
276-2. a glyph for common status icons, such as a muted speaker;
277-3. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
278-4. the first letter of its name, in a circle.
275+1. your choice in `[tray.icons]` (see below);
276+2. a glyph for standard status icons, such as a muted speaker or a Wi-Fi signal;
277+3. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
278+4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279+5. the first letter of its name, in a circle.
279280
280-The icon takes the main color of the application's own icon, lightened if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
281+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
281282
282-Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name or the text to show:
283+Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name, the text to show, or `""` to hide the item:
283284
284285 ```toml
285286 [tray.icons]
286287 sunshine = "md-weather_sunny"
287288 handy = "H"
289+nm-applet = ""
288290 ```
289291
290292 The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
@@ -272,19 +272,21 @@ exec = '"$NEFERBAR_BIN" tray'
272 272
273 Each icon is chosen without a list to maintain, from what the system already knows:273 Each icon is chosen without a list to maintain, from what the system already knows:
274 274
275-1. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;275+1. your choice in `[tray.icons]` (see below);
276-2. a glyph for common status icons, such as a muted speaker;276+2. a glyph for standard status icons, such as a muted speaker or a Wi-Fi signal;
277-3. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;277+3. a glyph of your font named after the application, such as `fa-steam` or `linux-gimp`;
278-4. the first letter of its name, in a circle.278+4. a glyph for the category of the application's `.desktop` file: a gamepad for a game, a globe for a browser;
279+5. the first letter of its name, in a circle.
279 280
280-The icon takes the main color of the application's own icon, lightened if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.281+The icon takes the main color of the application's own icon, moved toward the bar's text color if it would not show on the bar. Hidden items are left out, and an item that asks for attention turns bold, in the accent color. Icons only show: clicks and menus are not supported.
281 282
282-Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name or the text to show:283+Pick another icon with `[tray.icons]`. The key is the item's id or application name, in any case; the value is a glyph name, the text to show, or `""` to hide the item:
283 284
284 ```toml285 ```toml
285 [tray.icons]286 [tray.icons]
286 sunshine = "md-weather_sunny"287 sunshine = "md-weather_sunny"
287 handy = "H"288 handy = "H"
289+nm-applet = ""
288 ```290 ```
289 291
290 The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.292 The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it.
M cmd/neferbar/tray.go +16 -10
@@ -28,7 +28,7 @@ Run it as a module:
2828 [[module]]
2929 name = "tray"
3030 zone = "right"
31- exec = "neferbar tray"
31+ exec = '"$NEFERBAR_BIN" tray'
3232
3333 flags:
3434 `
@@ -74,7 +74,7 @@ func runTray(ctx context.Context, args []string) error {
7474 Out: os.Stdout,
7575 Log: log,
7676 }
77- opt.Foreground, opt.Background, opt.Accent = trayColors()
77+ opt.Foreground, opt.Background, opt.Accent = trayColors(log)
7878
7979 c, err := zerobus.SessionBus()
8080 if err != nil {
@@ -89,16 +89,22 @@ func runTray(ctx context.Context, args []string) error {
8989
9090 // trayColors are the colors the bar gives its scripts, so the tray matches
9191 // the bar. Run outside the bar, in a terminal, it uses the default theme.
92-func trayColors() (fg, bg, accent tray.RGB) {
92+func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) {
9393 def := theme.Default()
94- return envColor("NEFERBAR_FOREGROUND", def.Foreground),
95- envColor("NEFERBAR_BACKGROUND", def.Background),
96- envColor("NEFERBAR_ACCENT", def.Palette[4])
94+ return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground),
95+ envColor(log, "NEFERBAR_BACKGROUND", def.Background),
96+ envColor(log, "NEFERBAR_ACCENT", def.Palette[4])
9797 }
9898
99-func envColor(name string, def tray.RGB) tray.RGB {
100- if c, err := config.ParseColor(os.Getenv(name)); err == nil {
101- return c
99+func envColor(log *slog.Logger, name string, def tray.RGB) tray.RGB {
100+ v, ok := os.LookupEnv(name)
101+ if !ok {
102+ return def
102103 }
103- return def
104+ c, err := config.ParseColor(v)
105+ if err != nil {
106+ log.Warn("tray: using the default color", "var", name, "err", err)
107+ return def
108+ }
109+ return c
104110 }
@@ -28,7 +28,7 @@ Run it as a module:
28 [[module]]28 [[module]]
29 name = "tray"29 name = "tray"
30 zone = "right"30 zone = "right"
31- exec = "neferbar tray"31+ exec = '"$NEFERBAR_BIN" tray'
32 32
33 flags:33 flags:
34 `34 `
@@ -74,7 +74,7 @@ func runTray(ctx context.Context, args []string) error {
74 Out: os.Stdout,74 Out: os.Stdout,
75 Log: log,75 Log: log,
76 }76 }
77- opt.Foreground, opt.Background, opt.Accent = trayColors()77+ opt.Foreground, opt.Background, opt.Accent = trayColors(log)
78 78
79 c, err := zerobus.SessionBus()79 c, err := zerobus.SessionBus()
80 if err != nil {80 if err != nil {
@@ -89,16 +89,22 @@ func runTray(ctx context.Context, args []string) error {
89 89
90 // trayColors are the colors the bar gives its scripts, so the tray matches90 // trayColors are the colors the bar gives its scripts, so the tray matches
91 // the bar. Run outside the bar, in a terminal, it uses the default theme.91 // the bar. Run outside the bar, in a terminal, it uses the default theme.
92-func trayColors() (fg, bg, accent tray.RGB) {92+func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) {
93 def := theme.Default()93 def := theme.Default()
94- return envColor("NEFERBAR_FOREGROUND", def.Foreground),94+ return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground),
95- envColor("NEFERBAR_BACKGROUND", def.Background),95+ envColor(log, "NEFERBAR_BACKGROUND", def.Background),
96- envColor("NEFERBAR_ACCENT", def.Palette[4])96+ envColor(log, "NEFERBAR_ACCENT", def.Palette[4])
97 }97 }
98 98
99-func envColor(name string, def tray.RGB) tray.RGB {99+func envColor(log *slog.Logger, name string, def tray.RGB) tray.RGB {
100- if c, err := config.ParseColor(os.Getenv(name)); err == nil {100+ v, ok := os.LookupEnv(name)
101- return c101+ if !ok {
102+ return def
102 }103 }
103- return def104+ c, err := config.ParseColor(v)
105+ if err != nil {
106+ log.Warn("tray: using the default color", "var", name, "err", err)
107+ return def
108+ }
109+ return c
104 }110 }
M internal/config/config.go +9 -1
@@ -5,6 +5,8 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
9+ "unicode"
810
911 "github.com/BurntSushi/toml"
1012 )
@@ -41,7 +43,7 @@ type Module struct {
4143 type Tray struct {
4244 // Icons replaces the icon chosen for an item. The key is the item id or
4345 // application name, in any case; the value is a Nerd Font glyph name such
44- // as "fa-steam", or the text to show.
46+ // as "fa-steam", or the text to show; "" hides the item.
4547 Icons map[string]string `toml:"icons"`
4648 }
4749
@@ -135,6 +137,12 @@ func (c *Config) Validate() error {
135137 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
136138 }
137139 }
140+ for k, v := range c.Tray.Icons {
141+ // The tray prints the value on its one output line.
142+ if strings.ContainsFunc(v, unicode.IsControl) {
143+ return fmt.Errorf("config: tray.icons.%s contains a control character", k)
144+ }
145+ }
138146 return nil
139147 }
140148
@@ -5,6 +5,8 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
9+ "unicode"
8 10
9 "github.com/BurntSushi/toml"11 "github.com/BurntSushi/toml"
10 )12 )
@@ -41,7 +43,7 @@ type Module struct {
41 type Tray struct {43 type Tray struct {
42 // Icons replaces the icon chosen for an item. The key is the item id or44 // Icons replaces the icon chosen for an item. The key is the item id or
43 // application name, in any case; the value is a Nerd Font glyph name such45 // application name, in any case; the value is a Nerd Font glyph name such
44- // as "fa-steam", or the text to show.46+ // as "fa-steam", or the text to show; "" hides the item.
45 Icons map[string]string `toml:"icons"`47 Icons map[string]string `toml:"icons"`
46 }48 }
47 49
@@ -135,6 +137,12 @@ func (c *Config) Validate() error {
135 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)137 return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name)
136 }138 }
137 }139 }
140+ for k, v := range c.Tray.Icons {
141+ // The tray prints the value on its one output line.
142+ if strings.ContainsFunc(v, unicode.IsControl) {
143+ return fmt.Errorf("config: tray.icons.%s contains a control character", k)
144+ }
145+ }
138 return nil146 return nil
139 }147 }
140 148
M internal/config/config_test.go +11 -10
@@ -31,16 +31,17 @@ func TestLoadDefaultsAndModules(t *testing.T) {
3131
3232 func TestLoadErrors(t *testing.T) {
3333 for name, body := range map[string]string{
34- "unknown key": "[bar]\nfnot = 1\n",
35- "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36- "no exec": "[[module]]\nzone = \"left\"\n",
37- "bad size": "[bar]\nsize = 1\n",
38- "bad scale": "[bar]\nscale = 20\n",
39- "bad color": "[bar]\nbackground = \"red\"\n",
40- "position": "[bar]\nposition = \"middle\"\n",
41- "accent low": "[bar]\naccent = -1\n",
42- "accent high": "[bar]\naccent = 16\n",
43- "syntax error": "[bar\n",
34+ "unknown key": "[bar]\nfnot = 1\n",
35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36+ "no exec": "[[module]]\nzone = \"left\"\n",
37+ "bad size": "[bar]\nsize = 1\n",
38+ "bad scale": "[bar]\nscale = 20\n",
39+ "bad color": "[bar]\nbackground = \"red\"\n",
40+ "position": "[bar]\nposition = \"middle\"\n",
41+ "accent low": "[bar]\naccent = -1\n",
42+ "accent high": "[bar]\naccent = 16\n",
43+ "syntax error": "[bar\n",
44+ "tray icon newline": "[tray.icons]\nsteam = \"a\\nb\"\n",
4445 } {
4546 if _, err := Load(write(t, body), true); err == nil {
4647 t.Errorf("%s: want error", name)
@@ -31,16 +31,17 @@ func TestLoadDefaultsAndModules(t *testing.T) {
31 31
32 func TestLoadErrors(t *testing.T) {32 func TestLoadErrors(t *testing.T) {
33 for name, body := range map[string]string{33 for name, body := range map[string]string{
34- "unknown key": "[bar]\nfnot = 1\n",34+ "unknown key": "[bar]\nfnot = 1\n",
35- "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",35+ "bad zone": "[[module]]\nzone = \"top\"\nexec = \"x\"\n",
36- "no exec": "[[module]]\nzone = \"left\"\n",36+ "no exec": "[[module]]\nzone = \"left\"\n",
37- "bad size": "[bar]\nsize = 1\n",37+ "bad size": "[bar]\nsize = 1\n",
38- "bad scale": "[bar]\nscale = 20\n",38+ "bad scale": "[bar]\nscale = 20\n",
39- "bad color": "[bar]\nbackground = \"red\"\n",39+ "bad color": "[bar]\nbackground = \"red\"\n",
40- "position": "[bar]\nposition = \"middle\"\n",40+ "position": "[bar]\nposition = \"middle\"\n",
41- "accent low": "[bar]\naccent = -1\n",41+ "accent low": "[bar]\naccent = -1\n",
42- "accent high": "[bar]\naccent = 16\n",42+ "accent high": "[bar]\naccent = 16\n",
43- "syntax error": "[bar\n",43+ "syntax error": "[bar\n",
44+ "tray icon newline": "[tray.icons]\nsteam = \"a\\nb\"\n",
44 } {45 } {
45 if _, err := Load(write(t, body), true); err == nil {46 if _, err := Load(write(t, body), true); err == nil {
46 t.Errorf("%s: want error", name)47 t.Errorf("%s: want error", name)
M internal/glyph/names.go +2 -10
@@ -1,6 +1,7 @@
11 package glyph
22
33 import (
4+ "bytes"
45 "encoding/binary"
56 "fmt"
67 "slices"
@@ -54,20 +55,11 @@ func LoadNames(path string) (*Names, error) {
5455 }
5556 }
5657 slices.SortFunc(n.idx, func(a, b nameEntry) int {
57- x, y := n.name(a), n.name(b)
58- switch {
59- case x < y:
60- return -1
61- case x > y:
62- return 1
63- }
64- return 0
58+ return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n])
6559 })
6660 return n, nil
6761 }
6862
69-func (n *Names) name(e nameEntry) string { return string(n.data[e.off : e.off+e.n]) }
70-
7163 // Lookup returns the rune of the glyph called name.
7264 func (n *Names) Lookup(name string) (rune, bool) {
7365 if n == nil {
@@ -1,6 +1,7 @@
1 package glyph1 package glyph
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/binary"5 "encoding/binary"
5 "fmt"6 "fmt"
6 "slices"7 "slices"
@@ -54,20 +55,11 @@ func LoadNames(path string) (*Names, error) {
54 }55 }
55 }56 }
56 slices.SortFunc(n.idx, func(a, b nameEntry) int {57 slices.SortFunc(n.idx, func(a, b nameEntry) int {
57- x, y := n.name(a), n.name(b)58+ return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n])
58- switch {
59- case x < y:
60- return -1
61- case x > y:
62- return 1
63- }
64- return 0
65 })59 })
66 return n, nil60 return n, nil
67 }61 }
68 62
69-func (n *Names) name(e nameEntry) string { return string(n.data[e.off : e.off+e.n]) }
70-
71 // Lookup returns the rune of the glyph called name.63 // Lookup returns the rune of the glyph called name.
72 func (n *Names) Lookup(name string) (rune, bool) {64 func (n *Names) Lookup(name string) (rune, bool) {
73 if n == nil {65 if n == nil {
M internal/glyph/names_test.go +63 -21
@@ -1,6 +1,7 @@
11 package glyph
22
33 import (
4+ "encoding/binary"
45 "os"
56 "strings"
67 "testing"
@@ -38,23 +39,56 @@ func TestNamesLookup(t *testing.T) {
3839 }
3940 }
4041
41-func TestNamesWithoutIcons(t *testing.T) {
42- // A font without icon names indexes nothing, and that is not an error.
43- paths, err := FindFonts("monospace")
44- if err != nil {
45- t.Skip(err)
42+func TestNilNamesLookup(t *testing.T) {
43+ var n *Names
44+ if _, ok := n.Lookup("fa-steam"); ok {
45+ t.Error("a nil index found a name")
4646 }
47- if _, err := os.Stat(paths[0]); err != nil {
48- t.Skip(err)
47+}
48+
49+// postFont builds a font file holding only a "post" table of the given
50+// version, with one glyph index per entry of indices and the custom names
51+// after them.
52+func postFont(version uint32, indices []uint16, names ...string) []byte {
53+ post := make([]byte, 34+2*len(indices))
54+ binary.BigEndian.PutUint32(post, version)
55+ binary.BigEndian.PutUint16(post[32:], uint16(len(indices)))
56+ for i, k := range indices {
57+ binary.BigEndian.PutUint16(post[34+2*i:], k)
4958 }
50- n, err := LoadNames(paths[0])
51- if err != nil {
52- t.Fatal(err)
59+ for _, s := range names {
60+ post = append(post, byte(len(s)))
61+ post = append(post, s...)
5362 }
54- defer n.Close()
55- var nilNames *Names
56- if _, ok := nilNames.Lookup("fa-steam"); ok {
57- t.Error("a nil index found a name")
63+ b := make([]byte, 12+16, 12+16+len(post))
64+ b[5] = 1 // one table
65+ copy(b[12:], "post")
66+ binary.BigEndian.PutUint32(b[12+8:], 28)
67+ binary.BigEndian.PutUint32(b[12+12:], uint32(len(post)))
68+ return append(b, post...)
69+}
70+
71+func TestPostNames(t *testing.T) {
72+ // Glyph 0 has a standard name, 1 and 2 custom ones, 3 a custom index
73+ // past the names.
74+ data := postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second")
75+ spans := postNames(data)
76+ if len(spans) != 4 {
77+ t.Fatalf("%d spans, want 4", len(spans))
78+ }
79+ name := func(g int) string { return string(data[spans[g].off : spans[g].off+spans[g].n]) }
80+ if spans[0].n != 0 || spans[3].n != 0 {
81+ t.Errorf("standard or missing names gave spans: %v", spans)
82+ }
83+ if name(1) != "second" || name(2) != "first" {
84+ t.Errorf("names = %q, %q", name(1), name(2))
85+ }
86+}
87+
88+func TestPostNamesVersion3(t *testing.T) {
89+ // A version 3 table stores no names: nothing to index, no error.
90+ if spans := postNames(postFont(0x00030000, []uint16{259}, "x")); spans != nil {
91+ t.Fatalf("got %d spans", len(spans))
5892 }
5993 }
6094
@@ -74,14 +108,22 @@ func TestPostNamesDamagedFont(t *testing.T) {
74108 _ = postNames(b)
75109 }
76110 // A "post" table that claims more glyphs than it holds.
77- b := make([]byte, 12+16+40)
78- b[5] = 1 // one table
79- copy(b[12:], "post")
80- b[12+11] = 28 // offset 28
81- b[12+15] = 40 // length 40
82- b[28+1] = 2 // version 2.0
83- b[28+32] = 0xff
111+ b := postFont(0x00020000, nil)
112+ binary.BigEndian.PutUint16(b[28+32:], 0xff)
84113 if spans := postNames(b); spans != nil {
85114 t.Fatalf("got %d spans from a bad table", len(spans))
86115 }
87116 }
117+
118+func FuzzPostNames(f *testing.F) {
119+ f.Add(postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second"))
120+ f.Add(postFont(0x00030000, nil))
121+ f.Add([]byte{0, 1, 0, 0})
122+ f.Fuzz(func(t *testing.T, data []byte) {
123+ for g, s := range postNames(data) {
124+ if uint64(s.off)+uint64(s.n) > uint64(len(data)) {
125+ t.Fatalf("glyph %d: span %v past %d bytes", g, s, len(data))
126+ }
127+ }
128+ })
129+}
@@ -1,6 +1,7 @@
1 package glyph1 package glyph
2 2
3 import (3 import (
4+ "encoding/binary"
4 "os"5 "os"
5 "strings"6 "strings"
6 "testing"7 "testing"
@@ -38,23 +39,56 @@ func TestNamesLookup(t *testing.T) {
38 }39 }
39 }40 }
40 41
41-func TestNamesWithoutIcons(t *testing.T) {42+func TestNilNamesLookup(t *testing.T) {
42- // A font without icon names indexes nothing, and that is not an error.43+ var n *Names
43- paths, err := FindFonts("monospace")44+ if _, ok := n.Lookup("fa-steam"); ok {
44- if err != nil {45+ t.Error("a nil index found a name")
45- t.Skip(err)
46 }46 }
47- if _, err := os.Stat(paths[0]); err != nil {47+}
48- t.Skip(err)48+
49+// postFont builds a font file holding only a "post" table of the given
50+// version, with one glyph index per entry of indices and the custom names
51+// after them.
52+func postFont(version uint32, indices []uint16, names ...string) []byte {
53+ post := make([]byte, 34+2*len(indices))
54+ binary.BigEndian.PutUint32(post, version)
55+ binary.BigEndian.PutUint16(post[32:], uint16(len(indices)))
56+ for i, k := range indices {
57+ binary.BigEndian.PutUint16(post[34+2*i:], k)
49 }58 }
50- n, err := LoadNames(paths[0])59+ for _, s := range names {
51- if err != nil {60+ post = append(post, byte(len(s)))
52- t.Fatal(err)61+ post = append(post, s...)
53 }62 }
54- defer n.Close()63+ b := make([]byte, 12+16, 12+16+len(post))
55- var nilNames *Names64+ b[5] = 1 // one table
56- if _, ok := nilNames.Lookup("fa-steam"); ok {65+ copy(b[12:], "post")
57- t.Error("a nil index found a name")66+ binary.BigEndian.PutUint32(b[12+8:], 28)
67+ binary.BigEndian.PutUint32(b[12+12:], uint32(len(post)))
68+ return append(b, post...)
69+}
70+
71+func TestPostNames(t *testing.T) {
72+ // Glyph 0 has a standard name, 1 and 2 custom ones, 3 a custom index
73+ // past the names.
74+ data := postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second")
75+ spans := postNames(data)
76+ if len(spans) != 4 {
77+ t.Fatalf("%d spans, want 4", len(spans))
78+ }
79+ name := func(g int) string { return string(data[spans[g].off : spans[g].off+spans[g].n]) }
80+ if spans[0].n != 0 || spans[3].n != 0 {
81+ t.Errorf("standard or missing names gave spans: %v", spans)
82+ }
83+ if name(1) != "second" || name(2) != "first" {
84+ t.Errorf("names = %q, %q", name(1), name(2))
85+ }
86+}
87+
88+func TestPostNamesVersion3(t *testing.T) {
89+ // A version 3 table stores no names: nothing to index, no error.
90+ if spans := postNames(postFont(0x00030000, []uint16{259}, "x")); spans != nil {
91+ t.Fatalf("got %d spans", len(spans))
58 }92 }
59 }93 }
60 94
@@ -74,14 +108,22 @@ func TestPostNamesDamagedFont(t *testing.T) {
74 _ = postNames(b)108 _ = postNames(b)
75 }109 }
76 // A "post" table that claims more glyphs than it holds.110 // A "post" table that claims more glyphs than it holds.
77- b := make([]byte, 12+16+40)111+ b := postFont(0x00020000, nil)
78- b[5] = 1 // one table112+ binary.BigEndian.PutUint16(b[28+32:], 0xff)
79- copy(b[12:], "post")
80- b[12+11] = 28 // offset 28
81- b[12+15] = 40 // length 40
82- b[28+1] = 2 // version 2.0
83- b[28+32] = 0xff
84 if spans := postNames(b); spans != nil {113 if spans := postNames(b); spans != nil {
85 t.Fatalf("got %d spans from a bad table", len(spans))114 t.Fatalf("got %d spans from a bad table", len(spans))
86 }115 }
87 }116 }
117+
118+func FuzzPostNames(f *testing.F) {
119+ f.Add(postFont(0x00020000, []uint16{3, 259, 258, 300}, "first", "second"))
120+ f.Add(postFont(0x00030000, nil))
121+ f.Add([]byte{0, 1, 0, 0})
122+ f.Fuzz(func(t *testing.T, data []byte) {
123+ for g, s := range postNames(data) {
124+ if uint64(s.off)+uint64(s.n) > uint64(len(data)) {
125+ t.Fatalf("glyph %d: span %v past %d bytes", g, s, len(data))
126+ }
127+ }
128+ })
129+}
M internal/tray/color.go +14 -12
@@ -1,6 +1,15 @@
11 package tray
22
3-import "math"
3+import (
4+ "math"
5+
6+ "github.com/bnema/neferbar/internal/theme"
7+)
8+
9+// maxSamples caps the pixels counted per icon: enough for a color, and it
10+// keeps the sums below 2^32 (4096 * 255 * 255) and the cost of a large icon
11+// low.
12+const maxSamples = 4096
413
514 // histogram finds the dominant color of an icon. Colors are counted in 4096
615 // buckets (4 bits per channel), weighted by opacity and saturation so a logo's
@@ -13,14 +22,15 @@ type histogram struct {
1322
1423 // dominant returns the dominant color of argb, an SNI pixmap: big-endian
1524 // ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
16-// transparent or grey.
25+// transparent or grey. Large icons are sampled evenly.
1726 func (h *histogram) dominant(argb []byte) (RGB, bool) {
1827 if len(argb) < 4 {
1928 return RGB{}, false
2029 }
2130 clear(h.weight[:])
2231 clear(h.sum[:])
23- for i := 0; i+3 < len(argb); i += 4 {
32+ step := 4 * ((len(argb)/4 + maxSamples - 1) / maxSamples)
33+ for i := 0; i+3 < len(argb); i += step {
2434 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
2535 if a < 128 {
2636 continue
@@ -56,19 +66,11 @@ func (h *histogram) dominant(argb []byte) (RGB, bool) {
5666 func readable(c, bg, fg RGB) RGB {
5767 const minContrast = 3.0 // WCAG's minimum for large text and icons
5868 for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
59- c = mix(c, fg, 0.25)
69+ c = theme.Mix(c, fg, 0.25)
6070 }
6171 return c
6272 }
6373
64-func mix(a, b RGB, t float64) RGB {
65- var out RGB
66- for i := range out {
67- out[i] = uint8(math.Round(float64(a[i]) + (float64(b[i])-float64(a[i]))*t))
68- }
69- return out
70-}
71-
7274 // contrast is the WCAG contrast ratio of two colors, from 1 to 21.
7375 func contrast(a, b RGB) float64 {
7476 la, lb := luminance(a), luminance(b)
@@ -1,6 +1,15 @@
1 package tray1 package tray
2 2
3-import "math"3+import (
4+ "math"
5+
6+ "github.com/bnema/neferbar/internal/theme"
7+)
8+
9+// maxSamples caps the pixels counted per icon: enough for a color, and it
10+// keeps the sums below 2^32 (4096 * 255 * 255) and the cost of a large icon
11+// low.
12+const maxSamples = 4096
4 13
5 // histogram finds the dominant color of an icon. Colors are counted in 409614 // histogram finds the dominant color of an icon. Colors are counted in 4096
6 // buckets (4 bits per channel), weighted by opacity and saturation so a logo's15 // buckets (4 bits per channel), weighted by opacity and saturation so a logo's
@@ -13,14 +22,15 @@ type histogram struct {
13 22
14 // dominant returns the dominant color of argb, an SNI pixmap: big-endian23 // dominant returns the dominant color of argb, an SNI pixmap: big-endian
15 // ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully24 // ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully
16-// transparent or grey.25+// transparent or grey. Large icons are sampled evenly.
17 func (h *histogram) dominant(argb []byte) (RGB, bool) {26 func (h *histogram) dominant(argb []byte) (RGB, bool) {
18 if len(argb) < 4 {27 if len(argb) < 4 {
19 return RGB{}, false28 return RGB{}, false
20 }29 }
21 clear(h.weight[:])30 clear(h.weight[:])
22 clear(h.sum[:])31 clear(h.sum[:])
23- for i := 0; i+3 < len(argb); i += 4 {32+ step := 4 * ((len(argb)/4 + maxSamples - 1) / maxSamples)
33+ for i := 0; i+3 < len(argb); i += step {
24 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])34 a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3])
25 if a < 128 {35 if a < 128 {
26 continue36 continue
@@ -56,19 +66,11 @@ func (h *histogram) dominant(argb []byte) (RGB, bool) {
56 func readable(c, bg, fg RGB) RGB {66 func readable(c, bg, fg RGB) RGB {
57 const minContrast = 3.0 // WCAG's minimum for large text and icons67 const minContrast = 3.0 // WCAG's minimum for large text and icons
58 for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {68 for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ {
59- c = mix(c, fg, 0.25)69+ c = theme.Mix(c, fg, 0.25)
60 }70 }
61 return c71 return c
62 }72 }
63 73
64-func mix(a, b RGB, t float64) RGB {
65- var out RGB
66- for i := range out {
67- out[i] = uint8(math.Round(float64(a[i]) + (float64(b[i])-float64(a[i]))*t))
68- }
69- return out
70-}
71-
72 // contrast is the WCAG contrast ratio of two colors, from 1 to 21.74 // contrast is the WCAG contrast ratio of two colors, from 1 to 21.
73 func contrast(a, b RGB) float64 {75 func contrast(a, b RGB) float64 {
74 la, lb := luminance(a), luminance(b)76 la, lb := luminance(a), luminance(b)
M internal/tray/color_test.go +9 -0
@@ -36,6 +36,15 @@ func TestDominant(t *testing.T) {
3636 }
3737 }
3838
39+func TestDominantLargeIcon(t *testing.T) {
40+ // 512x512 of one saturated color: summing every pixel would overflow.
41+ var h histogram
42+ icon := pixmap(map[[4]uint8]int{{255, 250, 20, 20}: 512 * 512})
43+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
44+ t.Fatalf("dominant = %v, %v", got, ok)
45+ }
46+}
47+
3948 func TestReadable(t *testing.T) {
4049 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
4150 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
@@ -36,6 +36,15 @@ func TestDominant(t *testing.T) {
36 }36 }
37 }37 }
38 38
39+func TestDominantLargeIcon(t *testing.T) {
40+ // 512x512 of one saturated color: summing every pixel would overflow.
41+ var h histogram
42+ icon := pixmap(map[[4]uint8]int{{255, 250, 20, 20}: 512 * 512})
43+ if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) {
44+ t.Fatalf("dominant = %v, %v", got, ok)
45+ }
46+}
47+
39 func TestReadable(t *testing.T) {48 func TestReadable(t *testing.T) {
40 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}49 bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4}
41 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue50 dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue
M internal/tray/resolve.go +102 -35
@@ -5,6 +5,7 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8+ "time"
89 "unicode"
910 )
1011
@@ -12,8 +13,8 @@ import (
1213 // so a new application needs no table entry:
1314 //
1415 // 1. the user's [tray.icons] override;
15-// 2. a Nerd Font glyph named after the application, such as "fa-steam";
16-// 3. a glyph for a common status icon name, such as "audio-volume-muted";
16+// 2. a glyph for a freedesktop status icon name, such as "audio-volume-muted";
17+// 3. a Nerd Font glyph named after the application, such as "fa-steam";
1718 // 4. a glyph for the category of its .desktop file, such as Game;
1819 // 5. a letter in a circle.
1920 type Resolver struct {
@@ -21,6 +22,7 @@ type Resolver struct {
2122 icons map[string]string // lowercased keys
2223 dataDirs []string
2324 apps map[string]*desktopApp // nil until the first lookup
25+ loaded time.Time // when apps was read
2426 }
2527
2628 // desktopApp is what the resolver uses from a .desktop file.
@@ -28,6 +30,7 @@ type desktopApp struct {
2830 name string
2931 words []string // file name, Icon and StartupWMClass, lowercased
3032 categories []string
33+ hidden bool // Hidden=true: the application counts as deleted
3134 }
3235
3336 // NewResolver returns a resolver that finds glyphs with lookup (a font's
@@ -57,14 +60,30 @@ var stopwords = map[string]bool{
5760 "ayatana": true, "notification": true, "electron": true, "bin": true,
5861 }
5962
60-// statusIcons maps words of themed icon names to glyphs, most specific first.
63+// statusIcons maps freedesktop status icon names, by prefix, to glyphs, most
64+// specific first. A prefix matches whole dash-separated parts, so
65+// "signal-desktop" or "evolution-mail" are not status icons.
6166 var statusIcons = [...][2]string{
62- {"muted", "md-volume_off"}, {"volume", "md-volume_high"}, {"audio", "md-volume_high"},
63- {"microphone", "md-microphone"}, {"mic", "md-microphone"},
67+ {"audio-volume-muted", "md-volume_off"}, {"audio-volume", "md-volume_high"},
68+ {"microphone-sensitivity-muted", "md-microphone_off"}, {"microphone-sensitivity", "md-microphone"},
69+ {"audio-input-microphone-muted", "md-microphone_off"}, {"audio-input-microphone", "md-microphone"},
6470 {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
65- {"vpn", "md-vpn"}, {"wired", "md-lan"}, {"ethernet", "md-lan"},
66- {"wireless", "md-wifi"}, {"wifi", "md-wifi"}, {"signal", "md-wifi"}, {"network", "md-lan"},
67- {"update", "md-update"}, {"updates", "md-update"}, {"mail", "md-email"},
71+ {"network-vpn", "md-vpn"}, {"nm-vpn", "md-vpn"}, {"network-wired", "md-lan"}, {"nm-device-wired", "md-lan"},
72+ {"network-wireless", "md-wifi"}, {"nm-signal", "md-wifi"}, {"nm-device-wireless", "md-wifi"},
73+ {"network", "md-lan"},
74+ {"software-update", "md-update"}, {"system-software-update", "md-update"},
75+ {"mail-unread", "md-email"}, {"mail-read", "md-email"}, {"mail-message-new", "md-email"},
76+}
77+
78+// statusIcon returns the glyph name for a status icon name, or "".
79+func statusIcon(iconName string) string {
80+ n := strings.ToLower(iconName)
81+ for _, s := range statusIcons {
82+ if rest, ok := strings.CutPrefix(n, s[0]); ok && (rest == "" || rest[0] == '-') {
83+ return s[1]
84+ }
85+ }
86+ return ""
6887 }
6988
7089 // categories maps .desktop categories to glyphs, most specific first: an
@@ -82,7 +101,8 @@ var categories = [...][2]string{
82101
83102 // Resolve returns the icon text of an item: one glyph, or the user's
84103 // override. id, iconName and title are the item's properties; any may be
85-// empty.
104+// empty. A title can say anything ("Sync complete"), so it only counts as a
105+// whole name, after the stronger signals.
86106 func (r *Resolver) Resolve(id, iconName, title string) string {
87107 if strings.HasPrefix(iconName, "/") {
88108 iconName = "" // a file path names no application
@@ -93,24 +113,19 @@ func (r *Resolver) Resolve(id, iconName, title string) string {
93113 return r.glyphOr(v, v)
94114 }
95115 }
96- app := r.app(keys[:])
116+ app := r.app(id, iconName, title)
97117 if app != nil {
98118 if v, ok := r.icons[strings.ToLower(app.name)]; ok {
99119 return r.glyphOr(v, v)
100120 }
101121 }
102122 // Status icon names first: "audio-volume-muted" is a state, not a brand.
103- for _, w := range words(iconName) {
104- for _, s := range statusIcons {
105- if w == s[0] {
106- if g, ok := r.glyph(s[1]); ok {
107- return g
108- }
109- }
123+ if s := statusIcon(iconName); s != "" {
124+ if g, ok := r.glyph(s); ok {
125+ return g
110126 }
111127 }
112- var names []string
113- names = append(names, keys[:]...)
128+ names := []string{id, iconName}
114129 if app != nil {
115130 names = append(names, app.words...)
116131 names = append(names, app.name)
@@ -120,6 +135,9 @@ func (r *Resolver) Resolve(id, iconName, title string) string {
120135 return g
121136 }
122137 }
138+ if g, ok := r.prefixed(underscored(title)); ok {
139+ return g
140+ }
123141 if app != nil {
124142 for _, c := range categories {
125143 for _, ac := range app.categories {
@@ -162,16 +180,20 @@ func (r *Resolver) glyphOr(name, fallback string) string {
162180 return fallback
163181 }
164182
165-// brand finds a glyph named after s: its whole name, then each word.
166-func (r *Resolver) brand(s string) (string, bool) {
167- s = strings.ToLower(s)
168- whole := strings.Map(func(c rune) rune {
183+// underscored lowercases s and joins its parts with '_', the way glyph names
184+// are spelled: "Google Chrome" gives "google_chrome".
185+func underscored(s string) string {
186+ return strings.Map(func(c rune) rune {
169187 if c == '-' || c == '.' || c == ' ' {
170188 return '_'
171189 }
172- return c
190+ return unicode.ToLower(c)
173191 }, s)
174- if !stopwords[whole] {
192+}
193+
194+// brand finds a glyph named after s: its whole name, then each word.
195+func (r *Resolver) brand(s string) (string, bool) {
196+ if whole := underscored(s); !stopwords[whole] {
175197 if g, ok := r.prefixed(whole); ok {
176198 return g, true
177199 }
@@ -219,17 +241,37 @@ func firstLetter(s string) rune {
219241 return 0
220242 }
221243
222-// app finds the .desktop file of an item from its keys and their words.
223-func (r *Resolver) app(keys []string) *desktopApp {
244+// app finds the .desktop file of an item. The index is read on the first
245+// call, and again on a miss when an applications directory changed since,
246+// as when an application is installed during the session.
247+func (r *Resolver) app(id, iconName, title string) *desktopApp {
224248 if r.apps == nil {
225- r.apps = loadApps(r.dataDirs)
249+ r.reload()
226250 }
227- for _, k := range keys {
251+ if a := r.findApp(id, iconName, title); a != nil {
252+ return a
253+ }
254+ if r.changed() {
255+ r.reload()
256+ return r.findApp(id, iconName, title)
257+ }
258+ return nil
259+}
260+
261+func (r *Resolver) reload() {
262+ r.loaded = time.Now()
263+ r.apps = loadApps(r.dataDirs)
264+}
265+
266+// findApp looks up the whole id, icon name and title, then the words of the
267+// id and icon name. Titles are free text: their words are not used.
268+func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
269+ for _, k := range [...]string{id, iconName, title} {
228270 if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
229271 return a
230272 }
231273 }
232- for _, k := range keys {
274+ for _, k := range [...]string{id, iconName} {
233275 for _, w := range words(k) {
234276 if a := r.apps[w]; a != nil {
235277 return a
@@ -239,6 +281,20 @@ func (r *Resolver) app(keys []string) *desktopApp {
239281 return nil
240282 }
241283
284+// changed reports whether an applications directory was modified after the
285+// index was read.
286+func (r *Resolver) changed() bool {
287+ for _, d := range r.dataDirs {
288+ if d == "" {
289+ continue
290+ }
291+ if st, err := os.Stat(filepath.Join(d, "applications")); err == nil && !st.ModTime().Before(r.loaded) {
292+ return true
293+ }
294+ }
295+ return false
296+}
297+
242298 func xdgDataDirs() []string {
243299 home := os.Getenv("XDG_DATA_HOME")
244300 if home == "" {
@@ -254,8 +310,10 @@ func xdgDataDirs() []string {
254310 }
255311
256312 // loadApps indexes the .desktop files of dataDirs by lowercased file name,
257-// Icon and StartupWMClass, and by the words of those. Whole values win over
258-// words, and earlier directories over later ones, as in the XDG spec.
313+// Icon and StartupWMClass, and by the last part of reverse-DNS ones
314+// ("org.gnome.Settings" gives "settings" too). Whole values win over last
315+// parts, and earlier directories over later ones, as in the XDG spec. A
316+// hidden application keeps its keys, with no application behind them.
259317 func loadApps(dataDirs []string) map[string]*desktopApp {
260318 apps := map[string]*desktopApp{}
261319 var all []*desktopApp
@@ -271,7 +329,10 @@ func loadApps(dataDirs []string) map[string]*desktopApp {
271329 }
272330 }
273331 add := func(k string, a *desktopApp) {
274- if _, taken := apps[k]; !taken {
332+ if _, taken := apps[k]; !taken && k != "" {
333+ if a.hidden {
334+ a = nil
335+ }
275336 apps[k] = a
276337 }
277338 }
@@ -282,8 +343,8 @@ func loadApps(dataDirs []string) map[string]*desktopApp {
282343 }
283344 for _, a := range all {
284345 for _, v := range a.words {
285- for _, w := range words(v) {
286- add(w, a)
346+ if i := strings.LastIndexByte(v, '.'); i >= 0 {
347+ add(v[i+1:], a)
287348 }
288349 }
289350 }
@@ -327,6 +388,12 @@ func readDesktop(path string) *desktopApp {
327388 }
328389 case "Categories":
329390 a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
391+ case "Hidden":
392+ a.hidden = strings.TrimSpace(v) == "true"
393+ case "Type":
394+ if strings.TrimSpace(v) != "Application" {
395+ return nil
396+ }
330397 }
331398 }
332399 if !in {
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8+ "time"
8 "unicode"9 "unicode"
9 )10 )
10 11
@@ -12,8 +13,8 @@ import (
12 // so a new application needs no table entry:13 // so a new application needs no table entry:
13 //14 //
14 // 1. the user's [tray.icons] override;15 // 1. the user's [tray.icons] override;
15-// 2. a Nerd Font glyph named after the application, such as "fa-steam";16+// 2. a glyph for a freedesktop status icon name, such as "audio-volume-muted";
16-// 3. a glyph for a common status icon name, such as "audio-volume-muted";17+// 3. a Nerd Font glyph named after the application, such as "fa-steam";
17 // 4. a glyph for the category of its .desktop file, such as Game;18 // 4. a glyph for the category of its .desktop file, such as Game;
18 // 5. a letter in a circle.19 // 5. a letter in a circle.
19 type Resolver struct {20 type Resolver struct {
@@ -21,6 +22,7 @@ type Resolver struct {
21 icons map[string]string // lowercased keys22 icons map[string]string // lowercased keys
22 dataDirs []string23 dataDirs []string
23 apps map[string]*desktopApp // nil until the first lookup24 apps map[string]*desktopApp // nil until the first lookup
25+ loaded time.Time // when apps was read
24 }26 }
25 27
26 // desktopApp is what the resolver uses from a .desktop file.28 // desktopApp is what the resolver uses from a .desktop file.
@@ -28,6 +30,7 @@ type desktopApp struct {
28 name string30 name string
29 words []string // file name, Icon and StartupWMClass, lowercased31 words []string // file name, Icon and StartupWMClass, lowercased
30 categories []string32 categories []string
33+ hidden bool // Hidden=true: the application counts as deleted
31 }34 }
32 35
33 // NewResolver returns a resolver that finds glyphs with lookup (a font's36 // NewResolver returns a resolver that finds glyphs with lookup (a font's
@@ -57,14 +60,30 @@ var stopwords = map[string]bool{
57 "ayatana": true, "notification": true, "electron": true, "bin": true,60 "ayatana": true, "notification": true, "electron": true, "bin": true,
58 }61 }
59 62
60-// statusIcons maps words of themed icon names to glyphs, most specific first.63+// statusIcons maps freedesktop status icon names, by prefix, to glyphs, most
64+// specific first. A prefix matches whole dash-separated parts, so
65+// "signal-desktop" or "evolution-mail" are not status icons.
61 var statusIcons = [...][2]string{66 var statusIcons = [...][2]string{
62- {"muted", "md-volume_off"}, {"volume", "md-volume_high"}, {"audio", "md-volume_high"},67+ {"audio-volume-muted", "md-volume_off"}, {"audio-volume", "md-volume_high"},
63- {"microphone", "md-microphone"}, {"mic", "md-microphone"},68+ {"microphone-sensitivity-muted", "md-microphone_off"}, {"microphone-sensitivity", "md-microphone"},
69+ {"audio-input-microphone-muted", "md-microphone_off"}, {"audio-input-microphone", "md-microphone"},
64 {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},70 {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
65- {"vpn", "md-vpn"}, {"wired", "md-lan"}, {"ethernet", "md-lan"},71+ {"network-vpn", "md-vpn"}, {"nm-vpn", "md-vpn"}, {"network-wired", "md-lan"}, {"nm-device-wired", "md-lan"},
66- {"wireless", "md-wifi"}, {"wifi", "md-wifi"}, {"signal", "md-wifi"}, {"network", "md-lan"},72+ {"network-wireless", "md-wifi"}, {"nm-signal", "md-wifi"}, {"nm-device-wireless", "md-wifi"},
67- {"update", "md-update"}, {"updates", "md-update"}, {"mail", "md-email"},73+ {"network", "md-lan"},
74+ {"software-update", "md-update"}, {"system-software-update", "md-update"},
75+ {"mail-unread", "md-email"}, {"mail-read", "md-email"}, {"mail-message-new", "md-email"},
76+}
77+
78+// statusIcon returns the glyph name for a status icon name, or "".
79+func statusIcon(iconName string) string {
80+ n := strings.ToLower(iconName)
81+ for _, s := range statusIcons {
82+ if rest, ok := strings.CutPrefix(n, s[0]); ok && (rest == "" || rest[0] == '-') {
83+ return s[1]
84+ }
85+ }
86+ return ""
68 }87 }
69 88
70 // categories maps .desktop categories to glyphs, most specific first: an89 // categories maps .desktop categories to glyphs, most specific first: an
@@ -82,7 +101,8 @@ var categories = [...][2]string{
82 101
83 // Resolve returns the icon text of an item: one glyph, or the user's102 // Resolve returns the icon text of an item: one glyph, or the user's
84 // override. id, iconName and title are the item's properties; any may be103 // override. id, iconName and title are the item's properties; any may be
85-// empty.104+// empty. A title can say anything ("Sync complete"), so it only counts as a
105+// whole name, after the stronger signals.
86 func (r *Resolver) Resolve(id, iconName, title string) string {106 func (r *Resolver) Resolve(id, iconName, title string) string {
87 if strings.HasPrefix(iconName, "/") {107 if strings.HasPrefix(iconName, "/") {
88 iconName = "" // a file path names no application108 iconName = "" // a file path names no application
@@ -93,24 +113,19 @@ func (r *Resolver) Resolve(id, iconName, title string) string {
93 return r.glyphOr(v, v)113 return r.glyphOr(v, v)
94 }114 }
95 }115 }
96- app := r.app(keys[:])116+ app := r.app(id, iconName, title)
97 if app != nil {117 if app != nil {
98 if v, ok := r.icons[strings.ToLower(app.name)]; ok {118 if v, ok := r.icons[strings.ToLower(app.name)]; ok {
99 return r.glyphOr(v, v)119 return r.glyphOr(v, v)
100 }120 }
101 }121 }
102 // Status icon names first: "audio-volume-muted" is a state, not a brand.122 // Status icon names first: "audio-volume-muted" is a state, not a brand.
103- for _, w := range words(iconName) {123+ if s := statusIcon(iconName); s != "" {
104- for _, s := range statusIcons {124+ if g, ok := r.glyph(s); ok {
105- if w == s[0] {125+ return g
106- if g, ok := r.glyph(s[1]); ok {
107- return g
108- }
109- }
110 }126 }
111 }127 }
112- var names []string128+ names := []string{id, iconName}
113- names = append(names, keys[:]...)
114 if app != nil {129 if app != nil {
115 names = append(names, app.words...)130 names = append(names, app.words...)
116 names = append(names, app.name)131 names = append(names, app.name)
@@ -120,6 +135,9 @@ func (r *Resolver) Resolve(id, iconName, title string) string {
120 return g135 return g
121 }136 }
122 }137 }
138+ if g, ok := r.prefixed(underscored(title)); ok {
139+ return g
140+ }
123 if app != nil {141 if app != nil {
124 for _, c := range categories {142 for _, c := range categories {
125 for _, ac := range app.categories {143 for _, ac := range app.categories {
@@ -162,16 +180,20 @@ func (r *Resolver) glyphOr(name, fallback string) string {
162 return fallback180 return fallback
163 }181 }
164 182
165-// brand finds a glyph named after s: its whole name, then each word.183+// underscored lowercases s and joins its parts with '_', the way glyph names
166-func (r *Resolver) brand(s string) (string, bool) {184+// are spelled: "Google Chrome" gives "google_chrome".
167- s = strings.ToLower(s)185+func underscored(s string) string {
168- whole := strings.Map(func(c rune) rune {186+ return strings.Map(func(c rune) rune {
169 if c == '-' || c == '.' || c == ' ' {187 if c == '-' || c == '.' || c == ' ' {
170 return '_'188 return '_'
171 }189 }
172- return c190+ return unicode.ToLower(c)
173 }, s)191 }, s)
174- if !stopwords[whole] {192+}
193+
194+// brand finds a glyph named after s: its whole name, then each word.
195+func (r *Resolver) brand(s string) (string, bool) {
196+ if whole := underscored(s); !stopwords[whole] {
175 if g, ok := r.prefixed(whole); ok {197 if g, ok := r.prefixed(whole); ok {
176 return g, true198 return g, true
177 }199 }
@@ -219,17 +241,37 @@ func firstLetter(s string) rune {
219 return 0241 return 0
220 }242 }
221 243
222-// app finds the .desktop file of an item from its keys and their words.244+// app finds the .desktop file of an item. The index is read on the first
223-func (r *Resolver) app(keys []string) *desktopApp {245+// call, and again on a miss when an applications directory changed since,
246+// as when an application is installed during the session.
247+func (r *Resolver) app(id, iconName, title string) *desktopApp {
224 if r.apps == nil {248 if r.apps == nil {
225- r.apps = loadApps(r.dataDirs)249+ r.reload()
226 }250 }
227- for _, k := range keys {251+ if a := r.findApp(id, iconName, title); a != nil {
252+ return a
253+ }
254+ if r.changed() {
255+ r.reload()
256+ return r.findApp(id, iconName, title)
257+ }
258+ return nil
259+}
260+
261+func (r *Resolver) reload() {
262+ r.loaded = time.Now()
263+ r.apps = loadApps(r.dataDirs)
264+}
265+
266+// findApp looks up the whole id, icon name and title, then the words of the
267+// id and icon name. Titles are free text: their words are not used.
268+func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
269+ for _, k := range [...]string{id, iconName, title} {
228 if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {270 if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
229 return a271 return a
230 }272 }
231 }273 }
232- for _, k := range keys {274+ for _, k := range [...]string{id, iconName} {
233 for _, w := range words(k) {275 for _, w := range words(k) {
234 if a := r.apps[w]; a != nil {276 if a := r.apps[w]; a != nil {
235 return a277 return a
@@ -239,6 +281,20 @@ func (r *Resolver) app(keys []string) *desktopApp {
239 return nil281 return nil
240 }282 }
241 283
284+// changed reports whether an applications directory was modified after the
285+// index was read.
286+func (r *Resolver) changed() bool {
287+ for _, d := range r.dataDirs {
288+ if d == "" {
289+ continue
290+ }
291+ if st, err := os.Stat(filepath.Join(d, "applications")); err == nil && !st.ModTime().Before(r.loaded) {
292+ return true
293+ }
294+ }
295+ return false
296+}
297+
242 func xdgDataDirs() []string {298 func xdgDataDirs() []string {
243 home := os.Getenv("XDG_DATA_HOME")299 home := os.Getenv("XDG_DATA_HOME")
244 if home == "" {300 if home == "" {
@@ -254,8 +310,10 @@ func xdgDataDirs() []string {
254 }310 }
255 311
256 // loadApps indexes the .desktop files of dataDirs by lowercased file name,312 // loadApps indexes the .desktop files of dataDirs by lowercased file name,
257-// Icon and StartupWMClass, and by the words of those. Whole values win over313+// Icon and StartupWMClass, and by the last part of reverse-DNS ones
258-// words, and earlier directories over later ones, as in the XDG spec.314+// ("org.gnome.Settings" gives "settings" too). Whole values win over last
315+// parts, and earlier directories over later ones, as in the XDG spec. A
316+// hidden application keeps its keys, with no application behind them.
259 func loadApps(dataDirs []string) map[string]*desktopApp {317 func loadApps(dataDirs []string) map[string]*desktopApp {
260 apps := map[string]*desktopApp{}318 apps := map[string]*desktopApp{}
261 var all []*desktopApp319 var all []*desktopApp
@@ -271,7 +329,10 @@ func loadApps(dataDirs []string) map[string]*desktopApp {
271 }329 }
272 }330 }
273 add := func(k string, a *desktopApp) {331 add := func(k string, a *desktopApp) {
274- if _, taken := apps[k]; !taken {332+ if _, taken := apps[k]; !taken && k != "" {
333+ if a.hidden {
334+ a = nil
335+ }
275 apps[k] = a336 apps[k] = a
276 }337 }
277 }338 }
@@ -282,8 +343,8 @@ func loadApps(dataDirs []string) map[string]*desktopApp {
282 }343 }
283 for _, a := range all {344 for _, a := range all {
284 for _, v := range a.words {345 for _, v := range a.words {
285- for _, w := range words(v) {346+ if i := strings.LastIndexByte(v, '.'); i >= 0 {
286- add(w, a)347+ add(v[i+1:], a)
287 }348 }
288 }349 }
289 }350 }
@@ -327,6 +388,12 @@ func readDesktop(path string) *desktopApp {
327 }388 }
328 case "Categories":389 case "Categories":
329 a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })390 a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
391+ case "Hidden":
392+ a.hidden = strings.TrimSpace(v) == "true"
393+ case "Type":
394+ if strings.TrimSpace(v) != "Application" {
395+ return nil
396+ }
330 }397 }
331 }398 }
332 if !in {399 if !in {
M internal/tray/resolve_test.go +45 -1
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "testing"
7+ "time"
78 )
89
910 // fakeFont knows a few glyph names; each maps to a letter so results read
@@ -17,6 +18,10 @@ var fakeFont = map[string]rune{
1718 "md-lan": 'N',
1819 "md-alpha_z_circle": 'Z',
1920 "md-google_chrome": 'C',
21+ "md-wifi": 'F',
22+ "md-sync": 'Y',
23+ "md-chat": 'H',
24+ "md-cog": 'K',
2025 }
2126
2227 func lookup(name string) (rune, bool) {
@@ -41,7 +46,13 @@ func TestResolve(t *testing.T) {
4146 writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
4247 writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
4348 writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
44- r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir})
49+ writeDesktop(t, dir, "org.gnome.Settings", "[Desktop Entry]\nName=Settings\nCategories=Settings;\n")
50+ writeDesktop(t, dir, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Chat;\n")
51+ writeDesktop(t, dir, "ghost", "[Desktop Entry]\nName=Ghost\nHidden=true\n")
52+ later := t.TempDir()
53+ writeDesktop(t, later, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Game;\n")
54+ writeDesktop(t, later, "ghost", "[Desktop Entry]\nName=Ghost\nCategories=Game;\n")
55+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir, later})
4556
4657 for _, c := range []struct {
4758 name, id, icon, title, want string
@@ -50,6 +61,15 @@ func TestResolve(t *testing.T) {
5061 {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
5162 {"brand from the whole name", "google-chrome", "", "", "C"},
5263 {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
64+ {"status icon name before a brand", "steam", "audio-volume-muted", "", "M"},
65+ {"status word inside an app name is not a status", "signal-desktop", "signal-desktop", "", "S"},
66+ {"wireless status", "nm-applet", "nm-signal-75", "", "F"},
67+ {"title words are not brands", "", "", "Sync complete", "S"},
68+ {"whole title as a brand", "", "", "Google Chrome", "C"},
69+ {"title words find no desktop file", "", "", "Open settings", "O"},
70+ {"last part of a reverse-DNS name", "settings", "", "", "K"},
71+ {"earlier data dir wins", "chatter", "", "", "H"},
72+ {"hidden desktop file counts as deleted", "ghost", "", "", "G"},
5373 {"category from the desktop file", "racer", "racer-game", "", "G"},
5474 {"most specific category wins", "chromium", "", "", "W"},
5575 {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
@@ -66,6 +86,30 @@ func TestResolve(t *testing.T) {
6686 }
6787 }
6888
89+func TestResolveFindsAppsInstalledLater(t *testing.T) {
90+ dir := t.TempDir()
91+ r := NewResolver(lookup, nil, []string{dir})
92+ if got := r.Resolve("racer", "", ""); got != "R" {
93+ t.Fatalf("before install: %q", got)
94+ }
95+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96+ // The directory's mtime must move past the load time.
97+ future := time.Now().Add(time.Second)
98+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
99+ t.Fatal(err)
100+ }
101+ if got := r.Resolve("racer", "", ""); got != "G" {
102+ t.Fatalf("after install: %q, want G", got)
103+ }
104+}
105+
106+func TestEmptyOverrideHides(t *testing.T) {
107+ r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
108+ if got := r.Resolve("steam", "", ""); got != "" {
109+ t.Fatalf("Resolve = %q, want empty", got)
110+ }
111+}
112+
69113 func TestWords(t *testing.T) {
70114 got := words("org.telegram.desktop_2-Tray 42")
71115 if len(got) != 1 || got[0] != "telegram" {
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "testing"6 "testing"
7+ "time"
7 )8 )
8 9
9 // fakeFont knows a few glyph names; each maps to a letter so results read10 // fakeFont knows a few glyph names; each maps to a letter so results read
@@ -17,6 +18,10 @@ var fakeFont = map[string]rune{
17 "md-lan": 'N',18 "md-lan": 'N',
18 "md-alpha_z_circle": 'Z',19 "md-alpha_z_circle": 'Z',
19 "md-google_chrome": 'C',20 "md-google_chrome": 'C',
21+ "md-wifi": 'F',
22+ "md-sync": 'Y',
23+ "md-chat": 'H',
24+ "md-cog": 'K',
20 }25 }
21 26
22 func lookup(name string) (rune, bool) {27 func lookup(name string) (rune, bool) {
@@ -41,7 +46,13 @@ func TestResolve(t *testing.T) {
41 writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")46 writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n")
42 writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")47 writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n")
43 writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")48 writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n")
44- r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir})49+ writeDesktop(t, dir, "org.gnome.Settings", "[Desktop Entry]\nName=Settings\nCategories=Settings;\n")
50+ writeDesktop(t, dir, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Chat;\n")
51+ writeDesktop(t, dir, "ghost", "[Desktop Entry]\nName=Ghost\nHidden=true\n")
52+ later := t.TempDir()
53+ writeDesktop(t, later, "chatter", "[Desktop Entry]\nName=Chatter\nCategories=Game;\n")
54+ writeDesktop(t, later, "ghost", "[Desktop Entry]\nName=Ghost\nCategories=Game;\n")
55+ r := NewResolver(lookup, map[string]string{"Sunshine": "☀", "Mail": "md-lan"}, []string{dir, later})
45 56
46 for _, c := range []struct {57 for _, c := range []struct {
47 name, id, icon, title, want string58 name, id, icon, title, want string
@@ -50,6 +61,15 @@ func TestResolve(t *testing.T) {
50 {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},61 {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"},
51 {"brand from the whole name", "google-chrome", "", "", "C"},62 {"brand from the whole name", "google-chrome", "", "", "C"},
52 {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},63 {"status icon name", "nm-applet", "audio-volume-muted", "", "M"},
64+ {"status icon name before a brand", "steam", "audio-volume-muted", "", "M"},
65+ {"status word inside an app name is not a status", "signal-desktop", "signal-desktop", "", "S"},
66+ {"wireless status", "nm-applet", "nm-signal-75", "", "F"},
67+ {"title words are not brands", "", "", "Sync complete", "S"},
68+ {"whole title as a brand", "", "", "Google Chrome", "C"},
69+ {"title words find no desktop file", "", "", "Open settings", "O"},
70+ {"last part of a reverse-DNS name", "settings", "", "", "K"},
71+ {"earlier data dir wins", "chatter", "", "", "H"},
72+ {"hidden desktop file counts as deleted", "ghost", "", "", "G"},
53 {"category from the desktop file", "racer", "racer-game", "", "G"},73 {"category from the desktop file", "racer", "racer-game", "", "G"},
54 {"most specific category wins", "chromium", "", "", "W"},74 {"most specific category wins", "chromium", "", "", "W"},
55 {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},75 {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"},
@@ -66,6 +86,30 @@ func TestResolve(t *testing.T) {
66 }86 }
67 }87 }
68 88
89+func TestResolveFindsAppsInstalledLater(t *testing.T) {
90+ dir := t.TempDir()
91+ r := NewResolver(lookup, nil, []string{dir})
92+ if got := r.Resolve("racer", "", ""); got != "R" {
93+ t.Fatalf("before install: %q", got)
94+ }
95+ writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n")
96+ // The directory's mtime must move past the load time.
97+ future := time.Now().Add(time.Second)
98+ if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil {
99+ t.Fatal(err)
100+ }
101+ if got := r.Resolve("racer", "", ""); got != "G" {
102+ t.Fatalf("after install: %q, want G", got)
103+ }
104+}
105+
106+func TestEmptyOverrideHides(t *testing.T) {
107+ r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()})
108+ if got := r.Resolve("steam", "", ""); got != "" {
109+ t.Fatalf("Resolve = %q, want empty", got)
110+ }
111+}
112+
69 func TestWords(t *testing.T) {113 func TestWords(t *testing.T) {
70 got := words("org.telegram.desktop_2-Tray 42")114 got := words("org.telegram.desktop_2-Tray 42")
71 if len(got) != 1 || got[0] != "telegram" {115 if len(got) != 1 || got[0] != "telegram" {
M internal/tray/tray.go +87 -47
@@ -64,7 +64,8 @@ type item struct {
6464
6565 id, iconName, title string
6666 status status
67- icon string // resolved glyph
67+ icon string // resolved glyph; "" hides the item
68+ resolved bool
6869 color RGB
6970 hasColor bool
7071 loaded bool // GetAll answered at least once
@@ -118,7 +119,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
118119 c.Handle = t.dispatch
119120 for _, rule := range [...]string{
120121 "type='signal',interface='" + itemIface + "'",
121- "type='signal',interface='" + watcherIface + "'",
122+ // The bus matches a well-known sender by its current owner.
123+ "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
122124 "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
123125 } {
124126 if err := c.AddMatch(rule); err != nil {
@@ -130,7 +132,7 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
130132 }
131133 t.claim()
132134 if err := t.render(true); err != nil {
133- return err
135+ return t.exit(ctx, err)
134136 }
135137 for t.err == nil {
136138 m, err := c.ReadMessage()
@@ -139,7 +141,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
139141 }
140142 t.dispatch(m)
141143 }
142- return t.err
144+ // A send fails too when ctx ends during dispatch and closes c.
145+ return t.exit(ctx, t.err)
143146 }
144147
145148 // exit returns nil when the error comes from ctx ending.
@@ -206,7 +209,7 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
206209 switch p.kind {
207210 case pendName:
208211 code := m.Body().Uint32()
209- if !failed && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
212+ if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
210213 t.becomeWatcher()
211214 return
212215 }
@@ -217,12 +220,13 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
217220 return
218221 }
219222 r := m.Body()
220- if r.Variant() != "as" {
223+ if m.Signature != "v" || r.Variant() != "as" {
224+ t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
221225 return
222226 }
223227 end := r.Array('s')
224228 for r.More(end) {
225- t.add(r.Str(), "", "")
229+ t.add(r.Str(), "")
226230 }
227231 case pendGetAll:
228232 it := p.it
@@ -230,8 +234,8 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
230234 return // removed, or an older request
231235 }
232236 it.pending = 0
233- if failed {
234- t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName)
237+ if failed || m.Signature != "a{sv}" {
238+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
235239 if !it.loaded {
236240 t.remove(it)
237241 }
@@ -273,11 +277,17 @@ func (t *Tray) signal(m *zerobus.Message) {
273277 return
274278 }
275279 r := m.Body()
276- name, _, owner := r.Str(), r.Str(), r.Str()
277- if r.Err() != nil || owner != "" {
278- return
280+ name, old, owner := r.Str(), r.Str(), r.Str()
281+ switch {
282+ case r.Err() != nil:
283+ case owner == "":
284+ t.vanished(name)
285+ case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286+ // Another watcher replaced the one we used: its items are
287+ // the ones to show now.
288+ t.dropAll()
289+ t.becomeHost()
279290 }
280- t.vanished(name)
281291 case watcherIface:
282292 if t.watcher || m.Signature != "s" {
283293 return // our own signals come back to us too
@@ -285,13 +295,18 @@ func (t *Tray) signal(m *zerobus.Message) {
285295 s := m.Body().Str()
286296 switch m.Member {
287297 case "StatusNotifierItemRegistered":
288- t.add(s, "", "")
298+ t.add(s, "")
289299 case "StatusNotifierItemUnregistered":
290- if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == s }); i >= 0 {
300+ svc, _, _ := normService(s)
301+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
291302 t.remove(t.items[i])
292303 }
293304 }
294305 case itemIface:
306+ switch m.Member {
307+ case "NewToolTip", "NewMenu", "NewOverlayIcon":
308+ return // nothing the tray shows
309+ }
295310 for _, it := range t.items {
296311 if it.owner == m.Sender && it.path == m.Path {
297312 t.refresh(it)
@@ -316,38 +331,61 @@ func (t *Tray) vanished(name string) {
316331 }
317332 if name == watcherName && !t.watcher {
318333 // Items register again with the new watcher.
319- for i := len(t.items) - 1; i >= 0; i-- {
320- t.drop(i)
321- }
322- t.renderOrFail()
334+ t.dropAll()
323335 t.claim()
324336 }
325337 }
326338
339+func (t *Tray) dropAll() {
340+ if len(t.items) == 0 {
341+ return
342+ }
343+ for i := len(t.items) - 1; i >= 0; i-- {
344+ t.drop(i)
345+ }
346+ t.renderOrFail()
347+}
348+
349+// normService turns the watcher's form of an item, "bus/path" or "bus", into
350+// "bus/path", its bus and its path.
351+func normService(s string) (service, bus, path string) {
352+ bus, p, ok := strings.Cut(s, "/")
353+ if !ok {
354+ return bus + itemPath, bus, itemPath
355+ }
356+ return s, bus, "/" + p
357+}
358+
359+// addResult says what add did.
360+type addResult uint8
361+
362+const (
363+ addNew addResult = iota
364+ addKnown
365+ addInvalid // D-Bus refuses its bus name or path
366+)
367+
327368 // add starts tracking an item. service is the watcher's form, "bus/path" or
328369 // "bus"; owner is its unique name when known.
329-func (t *Tray) add(service, owner, path string) {
330- if path == "" {
331- bus, p, ok := strings.Cut(service, "/")
332- if ok {
333- path = "/" + p
334- } else {
335- path = itemPath
336- }
337- service = bus + path
338- if owner == "" && strings.HasPrefix(bus, ":") {
339- owner = bus
340- }
370+func (t *Tray) add(service, owner string) addResult {
371+ service, dest, path := normService(service)
372+ if owner == "" && strings.HasPrefix(dest, ":") {
373+ owner = dest
341374 }
342- dest, _, _ := strings.Cut(service, "/")
343375 for _, it := range t.items {
344376 if it.service == service || owner != "" && it.owner == owner && it.path == path {
345- return
377+ return addKnown
346378 }
347379 }
348380 it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
349381 t.items = append(t.items, it)
350382 t.refresh(it)
383+ if it.pending == 0 {
384+ // Never shown nor announced: forget it quietly.
385+ t.items = t.items[:len(t.items)-1]
386+ return addInvalid
387+ }
388+ return addNew
351389 }
352390
353391 func (t *Tray) remove(it *item) {
@@ -422,8 +460,8 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
422460 if it.hasColor {
423461 it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
424462 }
425- if it.icon == "" || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
426- it.icon = t.opt.Resolver.Resolve(it.id, it.iconName, it.title)
463+ if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
464+ it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
427465 }
428466 t.renderOrFail()
429467 }
@@ -493,20 +531,22 @@ func (t *Tray) call(m *zerobus.Message) {
493531 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
494532 return
495533 }
496- if !noReply {
497- t.c.NewReply(m, "")
498- t.send()
499- }
500- var owner, path string
501534 if strings.HasPrefix(s, "/") {
502- owner, path, s = m.Sender, s, m.Sender+s // registered by object path
535+ s = m.Sender + s // registered by object path
503536 } else {
504- owner, path, s = m.Sender, itemPath, s+itemPath
537+ s += itemPath
505538 }
506- n := len(t.items)
507- t.add(s, owner, path)
508- if len(t.items) > n {
509- t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[n].service)
539+ // add copies s before the next read, and sends only a GetAll.
540+ switch t.add(s, m.Sender) {
541+ case addInvalid:
542+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
543+ return
544+ case addNew:
545+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
546+ t.send()
547+ }
548+ if !noReply {
549+ t.c.NewReply(m, "")
510550 t.send()
511551 }
512552 case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
@@ -599,7 +639,7 @@ func (t *Tray) renderOrFail() {
599639 func (t *Tray) render(force bool) error {
600640 f := t.frame[:0]
601641 for _, it := range t.items {
602- if !it.loaded || it.status == passive {
642+ if !it.loaded || it.status == passive || it.icon == "" {
603643 continue
604644 }
605645 if len(f) > 0 {
@@ -64,7 +64,8 @@ type item struct {
64 64
65 id, iconName, title string65 id, iconName, title string
66 status status66 status status
67- icon string // resolved glyph67+ icon string // resolved glyph; "" hides the item
68+ resolved bool
68 color RGB69 color RGB
69 hasColor bool70 hasColor bool
70 loaded bool // GetAll answered at least once71 loaded bool // GetAll answered at least once
@@ -118,7 +119,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
118 c.Handle = t.dispatch119 c.Handle = t.dispatch
119 for _, rule := range [...]string{120 for _, rule := range [...]string{
120 "type='signal',interface='" + itemIface + "'",121 "type='signal',interface='" + itemIface + "'",
121- "type='signal',interface='" + watcherIface + "'",122+ // The bus matches a well-known sender by its current owner.
123+ "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'",
122 "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",124 "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'",
123 } {125 } {
124 if err := c.AddMatch(rule); err != nil {126 if err := c.AddMatch(rule); err != nil {
@@ -130,7 +132,7 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
130 }132 }
131 t.claim()133 t.claim()
132 if err := t.render(true); err != nil {134 if err := t.render(true); err != nil {
133- return err135+ return t.exit(ctx, err)
134 }136 }
135 for t.err == nil {137 for t.err == nil {
136 m, err := c.ReadMessage()138 m, err := c.ReadMessage()
@@ -139,7 +141,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error {
139 }141 }
140 t.dispatch(m)142 t.dispatch(m)
141 }143 }
142- return t.err144+ // A send fails too when ctx ends during dispatch and closes c.
145+ return t.exit(ctx, t.err)
143 }146 }
144 147
145 // exit returns nil when the error comes from ctx ending.148 // exit returns nil when the error comes from ctx ending.
@@ -206,7 +209,7 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
206 switch p.kind {209 switch p.kind {
207 case pendName:210 case pendName:
208 code := m.Body().Uint32()211 code := m.Body().Uint32()
209- if !failed && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {212+ if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) {
210 t.becomeWatcher()213 t.becomeWatcher()
211 return214 return
212 }215 }
@@ -217,12 +220,13 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
217 return220 return
218 }221 }
219 r := m.Body()222 r := m.Body()
220- if r.Variant() != "as" {223+ if m.Signature != "v" || r.Variant() != "as" {
224+ t.opt.Log.Warn("tray: the watcher listed its items in an unknown form")
221 return225 return
222 }226 }
223 end := r.Array('s')227 end := r.Array('s')
224 for r.More(end) {228 for r.More(end) {
225- t.add(r.Str(), "", "")229+ t.add(r.Str(), "")
226 }230 }
227 case pendGetAll:231 case pendGetAll:
228 it := p.it232 it := p.it
@@ -230,8 +234,8 @@ func (t *Tray) reply(p pend, m *zerobus.Message) {
230 return // removed, or an older request234 return // removed, or an older request
231 }235 }
232 it.pending = 0236 it.pending = 0
233- if failed {237+ if failed || m.Signature != "a{sv}" {
234- t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName)238+ t.opt.Log.Info("tray: item did not answer", "item", it.service, "err", m.ErrorName, "signature", m.Signature)
235 if !it.loaded {239 if !it.loaded {
236 t.remove(it)240 t.remove(it)
237 }241 }
@@ -273,11 +277,17 @@ func (t *Tray) signal(m *zerobus.Message) {
273 return277 return
274 }278 }
275 r := m.Body()279 r := m.Body()
276- name, _, owner := r.Str(), r.Str(), r.Str()280+ name, old, owner := r.Str(), r.Str(), r.Str()
277- if r.Err() != nil || owner != "" {281+ switch {
278- return282+ case r.Err() != nil:
283+ case owner == "":
284+ t.vanished(name)
285+ case name == watcherName && old != "" && !t.watcher && owner != t.c.UniqueName():
286+ // Another watcher replaced the one we used: its items are
287+ // the ones to show now.
288+ t.dropAll()
289+ t.becomeHost()
279 }290 }
280- t.vanished(name)
281 case watcherIface:291 case watcherIface:
282 if t.watcher || m.Signature != "s" {292 if t.watcher || m.Signature != "s" {
283 return // our own signals come back to us too293 return // our own signals come back to us too
@@ -285,13 +295,18 @@ func (t *Tray) signal(m *zerobus.Message) {
285 s := m.Body().Str()295 s := m.Body().Str()
286 switch m.Member {296 switch m.Member {
287 case "StatusNotifierItemRegistered":297 case "StatusNotifierItemRegistered":
288- t.add(s, "", "")298+ t.add(s, "")
289 case "StatusNotifierItemUnregistered":299 case "StatusNotifierItemUnregistered":
290- if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == s }); i >= 0 {300+ svc, _, _ := normService(s)
301+ if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 {
291 t.remove(t.items[i])302 t.remove(t.items[i])
292 }303 }
293 }304 }
294 case itemIface:305 case itemIface:
306+ switch m.Member {
307+ case "NewToolTip", "NewMenu", "NewOverlayIcon":
308+ return // nothing the tray shows
309+ }
295 for _, it := range t.items {310 for _, it := range t.items {
296 if it.owner == m.Sender && it.path == m.Path {311 if it.owner == m.Sender && it.path == m.Path {
297 t.refresh(it)312 t.refresh(it)
@@ -316,38 +331,61 @@ func (t *Tray) vanished(name string) {
316 }331 }
317 if name == watcherName && !t.watcher {332 if name == watcherName && !t.watcher {
318 // Items register again with the new watcher.333 // Items register again with the new watcher.
319- for i := len(t.items) - 1; i >= 0; i-- {334+ t.dropAll()
320- t.drop(i)
321- }
322- t.renderOrFail()
323 t.claim()335 t.claim()
324 }336 }
325 }337 }
326 338
339+func (t *Tray) dropAll() {
340+ if len(t.items) == 0 {
341+ return
342+ }
343+ for i := len(t.items) - 1; i >= 0; i-- {
344+ t.drop(i)
345+ }
346+ t.renderOrFail()
347+}
348+
349+// normService turns the watcher's form of an item, "bus/path" or "bus", into
350+// "bus/path", its bus and its path.
351+func normService(s string) (service, bus, path string) {
352+ bus, p, ok := strings.Cut(s, "/")
353+ if !ok {
354+ return bus + itemPath, bus, itemPath
355+ }
356+ return s, bus, "/" + p
357+}
358+
359+// addResult says what add did.
360+type addResult uint8
361+
362+const (
363+ addNew addResult = iota
364+ addKnown
365+ addInvalid // D-Bus refuses its bus name or path
366+)
367+
327 // add starts tracking an item. service is the watcher's form, "bus/path" or368 // add starts tracking an item. service is the watcher's form, "bus/path" or
328 // "bus"; owner is its unique name when known.369 // "bus"; owner is its unique name when known.
329-func (t *Tray) add(service, owner, path string) {370+func (t *Tray) add(service, owner string) addResult {
330- if path == "" {371+ service, dest, path := normService(service)
331- bus, p, ok := strings.Cut(service, "/")372+ if owner == "" && strings.HasPrefix(dest, ":") {
332- if ok {373+ owner = dest
333- path = "/" + p
334- } else {
335- path = itemPath
336- }
337- service = bus + path
338- if owner == "" && strings.HasPrefix(bus, ":") {
339- owner = bus
340- }
341 }374 }
342- dest, _, _ := strings.Cut(service, "/")
343 for _, it := range t.items {375 for _, it := range t.items {
344 if it.service == service || owner != "" && it.owner == owner && it.path == path {376 if it.service == service || owner != "" && it.owner == owner && it.path == path {
345- return377+ return addKnown
346 }378 }
347 }379 }
348 it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}380 it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)}
349 t.items = append(t.items, it)381 t.items = append(t.items, it)
350 t.refresh(it)382 t.refresh(it)
383+ if it.pending == 0 {
384+ // Never shown nor announced: forget it quietly.
385+ t.items = t.items[:len(t.items)-1]
386+ return addInvalid
387+ }
388+ return addNew
351 }389 }
352 390
353 func (t *Tray) remove(it *item) {391 func (t *Tray) remove(it *item) {
@@ -422,8 +460,8 @@ func (t *Tray) load(it *item, r *zerobus.Reader) {
422 if it.hasColor {460 if it.hasColor {
423 it.color = readable(it.color, t.opt.Background, t.opt.Foreground)461 it.color = readable(it.color, t.opt.Background, t.opt.Foreground)
424 }462 }
425- if it.icon == "" || it.id != was.id || it.iconName != was.iconName || it.title != was.title {463+ if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title {
426- it.icon = t.opt.Resolver.Resolve(it.id, it.iconName, it.title)464+ it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true
427 }465 }
428 t.renderOrFail()466 t.renderOrFail()
429 }467 }
@@ -493,20 +531,22 @@ func (t *Tray) call(m *zerobus.Message) {
493 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")531 t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service")
494 return532 return
495 }533 }
496- if !noReply {
497- t.c.NewReply(m, "")
498- t.send()
499- }
500- var owner, path string
501 if strings.HasPrefix(s, "/") {534 if strings.HasPrefix(s, "/") {
502- owner, path, s = m.Sender, s, m.Sender+s // registered by object path535+ s = m.Sender + s // registered by object path
503 } else {536 } else {
504- owner, path, s = m.Sender, itemPath, s+itemPath537+ s += itemPath
505 }538 }
506- n := len(t.items)539+ // add copies s before the next read, and sends only a GetAll.
507- t.add(s, owner, path)540+ switch t.add(s, m.Sender) {
508- if len(t.items) > n {541+ case addInvalid:
509- t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[n].service)542+ t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service")
543+ return
544+ case addNew:
545+ t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service)
546+ t.send()
547+ }
548+ if !noReply {
549+ t.c.NewReply(m, "")
510 t.send()550 t.send()
511 }551 }
512 case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":552 case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost":
@@ -599,7 +639,7 @@ func (t *Tray) renderOrFail() {
599 func (t *Tray) render(force bool) error {639 func (t *Tray) render(force bool) error {
600 f := t.frame[:0]640 f := t.frame[:0]
601 for _, it := range t.items {641 for _, it := range t.items {
602- if !it.loaded || it.status == passive {642+ if !it.loaded || it.status == passive || it.icon == "" {
603 continue643 continue
604 }644 }
605 if len(f) > 0 {645 if len(f) > 0 {
M internal/tray/tray_test.go +196 -43
@@ -70,7 +70,8 @@ func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeIt
7070 return f
7171 }
7272
73-// run makes the item's goroutine run fn, and waits for it.
73+// run hands fn to the item's goroutine, which runs it right after answering:
74+// run may return before fn ran. Tests wait for its effect on the output.
7475 func (f *fakeItem) run(t *testing.T, fn func()) {
7576 t.Helper()
7677 f.cmds <- fn
@@ -258,60 +259,212 @@ func TestTrayAsWatcher(t *testing.T) {
258259 }
259260 }
260261
261-func TestTrayAsHost(t *testing.T) {
262- addr := privateBus(t)
263- // Another program is the watcher. It lists one item and announces it.
264- other := dial(t, addr)
265- if code, err := other.RequestName(watcherName, zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
262+// waitWatcher waits until the tray owns the watcher name.
263+func waitWatcher(t *testing.T, addr string) {
264+ t.Helper()
265+ probe := dial(t, addr)
266+ deadline := time.Now().Add(5 * time.Second)
267+ for {
268+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
269+ m, err := probe.Call()
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ if m.Body().Bool() {
274+ return
275+ }
276+ if time.Now().After(deadline) {
277+ t.Fatal("the tray did not become the watcher")
278+ }
279+ time.Sleep(10 * time.Millisecond)
280+ }
281+}
282+
283+// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
284+// connection is used only by its own goroutine, which the test drives
285+// through Ping.
286+type fakeWatcher struct {
287+ c *zerobus.Conn
288+ probe *zerobus.Conn
289+ items []string
290+ cmds chan func()
291+ closed chan struct{}
292+}
293+
294+func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
295+ t.Helper()
296+ w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
297+ if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
266298 t.Fatalf("RequestName = %d, %v", code, err)
267299 }
268- obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
269- // A Conn is used from one goroutine: the fake watcher announces new
270- // items from its own loop, when the test pings it.
271- announce := make(chan string, 1)
272- go func() {
273- for {
274- m, err := other.ReadMessage()
275- if err != nil {
300+ go w.serve()
301+ return w
302+}
303+
304+func (w *fakeWatcher) serve() {
305+ defer close(w.closed)
306+ for {
307+ m, err := w.c.ReadMessage()
308+ if err != nil {
309+ return
310+ }
311+ if m.Type != zerobus.TypeMethodCall {
312+ continue
313+ }
314+ switch m.Member {
315+ case "Ping":
316+ w.c.NewReply(m, "")
317+ if _, err := w.c.Send(); err != nil {
276318 return
277319 }
278- if m.Type != zerobus.TypeMethodCall {
279- continue
280- }
281- switch m.Member {
282- case "Ping":
283- other.NewReply(m, "")
284- if _, err := other.Send(); err != nil {
285- return
286- }
287- other.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(<-announce)
288- case "Get":
289- e := other.NewReply(m, "v")
290- e.Variant("as")
291- a := e.BeginArray('s')
292- e.Str(obsidian.name + itemPath)
293- e.EndArray(a)
294- default:
295- other.NewReply(m, "")
296- }
297- if _, err := other.Send(); err != nil {
298- return
320+ (<-w.cmds)()
321+ continue
322+ case "Get":
323+ e := w.c.NewReply(m, "v")
324+ e.Variant("as")
325+ a := e.BeginArray('s')
326+ for _, s := range w.items {
327+ e.Str(s)
299328 }
329+ e.EndArray(a)
330+ default:
331+ w.c.NewReply(m, "")
300332 }
301- }()
333+ if _, err := w.c.Send(); err != nil {
334+ return
335+ }
336+ }
337+}
338+
339+// do hands fn to the watcher's goroutine; see fakeItem.run.
340+func (w *fakeWatcher) do(t *testing.T, fn func()) {
341+ t.Helper()
342+ w.cmds <- fn
343+ w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
344+ if _, err := w.probe.Call(); err != nil {
345+ t.Fatal(err)
346+ }
347+}
348+
349+func (w *fakeWatcher) emit(t *testing.T, member, service string) {
350+ w.do(t, func() {
351+ w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
352+ _, _ = w.c.Send()
353+ })
354+}
355+
356+func (w *fakeWatcher) quit(t *testing.T) {
357+ w.do(t, func() { _ = w.c.Close() })
358+ <-w.closed
359+}
360+
361+func TestTrayAsHost(t *testing.T) {
362+ addr := privateBus(t)
363+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
364+ // Another program is the watcher, and lists one item.
365+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
302366
303367 out, _, _ := startTray(t, addr)
304368 out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
305369
306- // A second item is announced by the other watcher.
370+ // A second item is announced by the other watcher, by bus name only.
307371 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
308- announce <- steam.name
309- probe := dial(t, addr)
310- probe.NewCall(other.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
311- if _, err := probe.Call(); err != nil {
372+ w.emit(t, "StatusNotifierItemRegistered", steam.name)
373+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
374+
375+ // And removed in the same form.
376+ w.emit(t, "StatusNotifierItemUnregistered", steam.name)
377+ out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
378+}
379+
380+func TestHostFollowsReplacedWatcher(t *testing.T) {
381+ addr := privateBus(t)
382+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
383+ newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
384+ out, _, _ := startTray(t, addr)
385+ out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
386+
387+ // A new panel replaces the watcher: its items are the ones to show.
388+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
389+ newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
390+ out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
391+}
392+
393+func TestHostTakesOverWatcher(t *testing.T) {
394+ addr := privateBus(t)
395+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
396+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
397+ out, cancel, done := startTray(t, addr)
398+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
399+
400+ // The other panel exits: the tray drops its items and becomes the
401+ // watcher, where items register again.
402+ w.quit(t)
403+ out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
404+ waitWatcher(t, addr)
405+ obsidian.register(t)
406+ out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
407+
408+ cancel()
409+ if err := <-done; err != nil {
410+ t.Fatalf("Run: %v", err)
411+ }
412+}
413+
414+// registered returns the watcher's RegisteredStatusNotifierItems.
415+func registered(t *testing.T, c *zerobus.Conn) []string {
416+ t.Helper()
417+ e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
418+ e.Str(watcherIface)
419+ e.Str("RegisteredStatusNotifierItems")
420+ m, err := c.Call()
421+ if err != nil {
312422 t.Fatal(err)
313423 }
314- out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
424+ r := m.Body()
425+ if v := r.Variant(); v != "as" {
426+ t.Fatalf("variant %q", v)
427+ }
428+ var items []string
429+ for end := r.Array('s'); r.More(end); {
430+ items = append(items, strings.Clone(r.Str()))
431+ }
432+ return items
433+}
434+
435+func TestWatcherRegistration(t *testing.T) {
436+ addr := privateBus(t)
437+ out, _, _ := startTray(t, addr)
438+ waitWatcher(t, addr)
439+
440+ // libappindicator and Electron register an object path, not a name.
441+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
442+ steam.run(t, func() {
443+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
444+ _, _ = steam.c.Send()
445+ })
446+ out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
447+
448+ // The same item again, by bus name: still one item.
449+ dup := make(chan error, 1)
450+ steam.run(t, func() {
451+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
452+ _, err := steam.c.Call()
453+ dup <- err
454+ })
455+ if err := <-dup; err != nil {
456+ t.Fatal(err)
457+ }
458+
459+ // A path D-Bus refuses is rejected and leaves no trace.
460+ probe := dial(t, addr)
461+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str("/bad//path")
462+ if _, err := probe.Call(); err == nil {
463+ t.Fatal("an invalid path was accepted")
464+ }
465+ if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
466+ t.Fatalf("registered items %q", items)
467+ }
315468 }
316469
317470 // TestRefreshNoAllocs checks the steady state: an item signals a change, the
@@ -324,7 +477,7 @@ func TestRefreshNoAllocs(t *testing.T) {
324477 tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
325478 frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
326479 tr.opt.Log = slog.New(slog.DiscardHandler)
327- tr.add(app.name, "", "")
480+ tr.add(app.name, "")
328481 cycle := func() {
329482 tr.refresh(tr.items[0])
330483 for len(tr.pending) > 0 {
@@ -70,7 +70,8 @@ func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeIt
70 return f70 return f
71 }71 }
72 72
73-// run makes the item's goroutine run fn, and waits for it.73+// run hands fn to the item's goroutine, which runs it right after answering:
74+// run may return before fn ran. Tests wait for its effect on the output.
74 func (f *fakeItem) run(t *testing.T, fn func()) {75 func (f *fakeItem) run(t *testing.T, fn func()) {
75 t.Helper()76 t.Helper()
76 f.cmds <- fn77 f.cmds <- fn
@@ -258,60 +259,212 @@ func TestTrayAsWatcher(t *testing.T) {
258 }259 }
259 }260 }
260 261
261-func TestTrayAsHost(t *testing.T) {262+// waitWatcher waits until the tray owns the watcher name.
262- addr := privateBus(t)263+func waitWatcher(t *testing.T, addr string) {
263- // Another program is the watcher. It lists one item and announces it.264+ t.Helper()
264- other := dial(t, addr)265+ probe := dial(t, addr)
265- if code, err := other.RequestName(watcherName, zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {266+ deadline := time.Now().Add(5 * time.Second)
267+ for {
268+ probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
269+ m, err := probe.Call()
270+ if err != nil {
271+ t.Fatal(err)
272+ }
273+ if m.Body().Bool() {
274+ return
275+ }
276+ if time.Now().After(deadline) {
277+ t.Fatal("the tray did not become the watcher")
278+ }
279+ time.Sleep(10 * time.Millisecond)
280+ }
281+}
282+
283+// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
284+// connection is used only by its own goroutine, which the test drives
285+// through Ping.
286+type fakeWatcher struct {
287+ c *zerobus.Conn
288+ probe *zerobus.Conn
289+ items []string
290+ cmds chan func()
291+ closed chan struct{}
292+}
293+
294+func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
295+ t.Helper()
296+ w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
297+ if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
266 t.Fatalf("RequestName = %d, %v", code, err)298 t.Fatalf("RequestName = %d, %v", code, err)
267 }299 }
268- obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})300+ go w.serve()
269- // A Conn is used from one goroutine: the fake watcher announces new301+ return w
270- // items from its own loop, when the test pings it.302+}
271- announce := make(chan string, 1)303+
272- go func() {304+func (w *fakeWatcher) serve() {
273- for {305+ defer close(w.closed)
274- m, err := other.ReadMessage()306+ for {
275- if err != nil {307+ m, err := w.c.ReadMessage()
308+ if err != nil {
309+ return
310+ }
311+ if m.Type != zerobus.TypeMethodCall {
312+ continue
313+ }
314+ switch m.Member {
315+ case "Ping":
316+ w.c.NewReply(m, "")
317+ if _, err := w.c.Send(); err != nil {
276 return318 return
277 }319 }
278- if m.Type != zerobus.TypeMethodCall {320+ (<-w.cmds)()
279- continue321+ continue
280- }322+ case "Get":
281- switch m.Member {323+ e := w.c.NewReply(m, "v")
282- case "Ping":324+ e.Variant("as")
283- other.NewReply(m, "")325+ a := e.BeginArray('s')
284- if _, err := other.Send(); err != nil {326+ for _, s := range w.items {
285- return327+ e.Str(s)
286- }
287- other.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(<-announce)
288- case "Get":
289- e := other.NewReply(m, "v")
290- e.Variant("as")
291- a := e.BeginArray('s')
292- e.Str(obsidian.name + itemPath)
293- e.EndArray(a)
294- default:
295- other.NewReply(m, "")
296- }
297- if _, err := other.Send(); err != nil {
298- return
299 }328 }
329+ e.EndArray(a)
330+ default:
331+ w.c.NewReply(m, "")
300 }332 }
301- }()333+ if _, err := w.c.Send(); err != nil {
334+ return
335+ }
336+ }
337+}
338+
339+// do hands fn to the watcher's goroutine; see fakeItem.run.
340+func (w *fakeWatcher) do(t *testing.T, fn func()) {
341+ t.Helper()
342+ w.cmds <- fn
343+ w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
344+ if _, err := w.probe.Call(); err != nil {
345+ t.Fatal(err)
346+ }
347+}
348+
349+func (w *fakeWatcher) emit(t *testing.T, member, service string) {
350+ w.do(t, func() {
351+ w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
352+ _, _ = w.c.Send()
353+ })
354+}
355+
356+func (w *fakeWatcher) quit(t *testing.T) {
357+ w.do(t, func() { _ = w.c.Close() })
358+ <-w.closed
359+}
360+
361+func TestTrayAsHost(t *testing.T) {
362+ addr := privateBus(t)
363+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
364+ // Another program is the watcher, and lists one item.
365+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
302 366
303 out, _, _ := startTray(t, addr)367 out, _, _ := startTray(t, addr)
304 out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })368 out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
305 369
306- // A second item is announced by the other watcher.370+ // A second item is announced by the other watcher, by bus name only.
307 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})371 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
308- announce <- steam.name372+ w.emit(t, "StatusNotifierItemRegistered", steam.name)
309- probe := dial(t, addr)373+ out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
310- probe.NewCall(other.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")374+
311- if _, err := probe.Call(); err != nil {375+ // And removed in the same form.
376+ w.emit(t, "StatusNotifierItemUnregistered", steam.name)
377+ out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
378+}
379+
380+func TestHostFollowsReplacedWatcher(t *testing.T) {
381+ addr := privateBus(t)
382+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
383+ newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
384+ out, _, _ := startTray(t, addr)
385+ out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
386+
387+ // A new panel replaces the watcher: its items are the ones to show.
388+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
389+ newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
390+ out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
391+}
392+
393+func TestHostTakesOverWatcher(t *testing.T) {
394+ addr := privateBus(t)
395+ obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
396+ w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
397+ out, cancel, done := startTray(t, addr)
398+ out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
399+
400+ // The other panel exits: the tray drops its items and becomes the
401+ // watcher, where items register again.
402+ w.quit(t)
403+ out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
404+ waitWatcher(t, addr)
405+ obsidian.register(t)
406+ out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
407+
408+ cancel()
409+ if err := <-done; err != nil {
410+ t.Fatalf("Run: %v", err)
411+ }
412+}
413+
414+// registered returns the watcher's RegisteredStatusNotifierItems.
415+func registered(t *testing.T, c *zerobus.Conn) []string {
416+ t.Helper()
417+ e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
418+ e.Str(watcherIface)
419+ e.Str("RegisteredStatusNotifierItems")
420+ m, err := c.Call()
421+ if err != nil {
312 t.Fatal(err)422 t.Fatal(err)
313 }423 }
314- out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })424+ r := m.Body()
425+ if v := r.Variant(); v != "as" {
426+ t.Fatalf("variant %q", v)
427+ }
428+ var items []string
429+ for end := r.Array('s'); r.More(end); {
430+ items = append(items, strings.Clone(r.Str()))
431+ }
432+ return items
433+}
434+
435+func TestWatcherRegistration(t *testing.T) {
436+ addr := privateBus(t)
437+ out, _, _ := startTray(t, addr)
438+ waitWatcher(t, addr)
439+
440+ // libappindicator and Electron register an object path, not a name.
441+ steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
442+ steam.run(t, func() {
443+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
444+ _, _ = steam.c.Send()
445+ })
446+ out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
447+
448+ // The same item again, by bus name: still one item.
449+ dup := make(chan error, 1)
450+ steam.run(t, func() {
451+ steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
452+ _, err := steam.c.Call()
453+ dup <- err
454+ })
455+ if err := <-dup; err != nil {
456+ t.Fatal(err)
457+ }
458+
459+ // A path D-Bus refuses is rejected and leaves no trace.
460+ probe := dial(t, addr)
461+ probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str("/bad//path")
462+ if _, err := probe.Call(); err == nil {
463+ t.Fatal("an invalid path was accepted")
464+ }
465+ if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
466+ t.Fatalf("registered items %q", items)
467+ }
315 }468 }
316 469
317 // TestRefreshNoAllocs checks the steady state: an item signals a change, the470 // TestRefreshNoAllocs checks the steady state: an item signals a change, the
@@ -324,7 +477,7 @@ func TestRefreshNoAllocs(t *testing.T) {
324 tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},477 tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
325 frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}478 frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
326 tr.opt.Log = slog.New(slog.DiscardHandler)479 tr.opt.Log = slog.New(slog.DiscardHandler)
327- tr.add(app.name, "", "")480+ tr.add(app.name, "")
328 cycle := func() {481 cycle := func() {
329 tr.refresh(tr.items[0])482 tr.refresh(tr.items[0])
330 for len(tr.pending) > 0 {483 for len(tr.pending) > 0 {

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 5

Tags 4

↑↓ moveenter openesc close