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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +562 -196
M README.md +8 -6
| @@ -272,19 +272,21 @@ exec = '"$NEFERBAR_BIN" tray' | |||
| 272 | 272 | ||
| 273 | 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`; | |
| 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. | |
| 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 | 285 | ```toml | |
| 285 | 286 | [tray.icons] | |
| 286 | 287 | sunshine = "md-weather_sunny" | |
| 287 | 288 | handy = "H" | |
| 289 | + | nm-applet = "" | |
| 288 | 290 | ``` | |
| 289 | 291 | ||
| 290 | 292 | 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 | ```toml | 285 | ```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: | |||
| 28 | 28 | [[module]] | |
| 29 | 29 | name = "tray" | |
| 30 | 30 | zone = "right" | |
| 31 | - | exec = "neferbar tray" | |
| 31 | + | exec = '"$NEFERBAR_BIN" tray' | |
| 32 | 32 | ||
| 33 | 33 | flags: | |
| 34 | 34 | ` | |
| @@ -74,7 +74,7 @@ func runTray(ctx context.Context, args []string) error { | |||
| 74 | 74 | Out: os.Stdout, | |
| 75 | 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 | 79 | c, err := zerobus.SessionBus() | |
| 80 | 80 | if err != nil { | |
| @@ -89,16 +89,22 @@ func runTray(ctx context.Context, args []string) error { | |||
| 89 | 89 | ||
| 90 | 90 | // trayColors are the colors the bar gives its scripts, so the tray matches | |
| 91 | 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 | 93 | 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]) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 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 | |
| 102 | 103 | } | |
| 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 | |
| 104 | 110 | } | |
| @@ -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 matches | 90 | // 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 c | 101 | + | if !ok { |
| 102 | + | return def | |||
| 102 | } | 103 | } | ||
| 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 | |||
| 104 | } | 110 | } | ||
M internal/config/config.go +9 -1
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "unicode" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/BurntSushi/toml" | |
| 10 | 12 | ) | |
| @@ -41,7 +43,7 @@ type Module struct { | |||
| 41 | 43 | type Tray struct { | |
| 42 | 44 | // Icons replaces the icon chosen for an item. The key is the item id or | |
| 43 | 45 | // 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 | 47 | Icons map[string]string `toml:"icons"` | |
| 46 | 48 | } | |
| 47 | 49 | ||
| @@ -135,6 +137,12 @@ func (c *Config) Validate() error { | |||
| 135 | 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 | 146 | return nil | |
| 139 | 147 | } | |
| 140 | 148 | ||
| @@ -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 or | 44 | // 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 such | 45 | // 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 nil | 146 | return nil | ||
| 139 | } | 147 | } | ||
| 140 | 148 | ||||
M internal/config/config_test.go +11 -10
| @@ -31,16 +31,17 @@ func TestLoadDefaultsAndModules(t *testing.T) { | |||
| 31 | 31 | ||
| 32 | 32 | func TestLoadErrors(t *testing.T) { | |
| 33 | 33 | 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", | |
| 44 | 45 | } { | |
| 45 | 46 | if _, err := Load(write(t, body), true); err == nil { | |
| 46 | 47 | 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 @@ | |||
| 1 | 1 | package glyph | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "encoding/binary" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "slices" | |
| @@ -54,20 +55,11 @@ func LoadNames(path string) (*Names, error) { | |||
| 54 | 55 | } | |
| 55 | 56 | } | |
| 56 | 57 | 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]) | |
| 65 | 59 | }) | |
| 66 | 60 | 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 | 63 | // Lookup returns the rune of the glyph called name. | |
| 72 | 64 | func (n *Names) Lookup(name string) (rune, bool) { | |
| 73 | 65 | if n == nil { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package glyph | 1 | 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, nil | 60 | 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 @@ | |||
| 1 | 1 | package glyph | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/binary" | |
| 4 | 5 | "os" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| @@ -38,23 +39,56 @@ func TestNamesLookup(t *testing.T) { | |||
| 38 | 39 | } | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 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") | |
| 46 | 46 | } | |
| 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) | |
| 49 | 58 | } | |
| 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...) | |
| 53 | 62 | } | |
| 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)) | |
| 58 | 92 | } | |
| 59 | 93 | } | |
| 60 | 94 | ||
| @@ -74,14 +108,22 @@ func TestPostNamesDamagedFont(t *testing.T) { | |||
| 74 | 108 | _ = postNames(b) | |
| 75 | 109 | } | |
| 76 | 110 | // 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) | |
| 84 | 113 | if spans := postNames(b); spans != nil { | |
| 85 | 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package glyph | 1 | 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 *Names | 64 | + | 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 table | 112 | + | 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 @@ | |||
| 1 | 1 | 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 | 14 | // histogram finds the dominant color of an icon. Colors are counted in 4096 | |
| 6 | 15 | // buckets (4 bits per channel), weighted by opacity and saturation so a logo's | |
| @@ -13,14 +22,15 @@ type histogram struct { | |||
| 13 | 22 | ||
| 14 | 23 | // dominant returns the dominant color of argb, an SNI pixmap: big-endian | |
| 15 | 24 | // 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 | 26 | func (h *histogram) dominant(argb []byte) (RGB, bool) { | |
| 18 | 27 | if len(argb) < 4 { | |
| 19 | 28 | return RGB{}, false | |
| 20 | 29 | } | |
| 21 | 30 | clear(h.weight[:]) | |
| 22 | 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 | 34 | a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3]) | |
| 25 | 35 | if a < 128 { | |
| 26 | 36 | continue | |
| @@ -56,19 +66,11 @@ func (h *histogram) dominant(argb []byte) (RGB, bool) { | |||
| 56 | 66 | func readable(c, bg, fg RGB) RGB { | |
| 57 | 67 | const minContrast = 3.0 // WCAG's minimum for large text and icons | |
| 58 | 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 | 71 | 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 | 74 | // contrast is the WCAG contrast ratio of two colors, from 1 to 21. | |
| 73 | 75 | func contrast(a, b RGB) float64 { | |
| 74 | 76 | la, lb := luminance(a), luminance(b) | |
| @@ -1,6 +1,15 @@ | |||||
| 1 | package tray | 1 | 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 4096 | 14 | // 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's | 15 | // 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-endian | 23 | // 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, fully | 24 | // 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{}, false | 28 | 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 | continue | 36 | 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 icons | 67 | 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 c | 71 | 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) { | |||
| 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 | 48 | func TestReadable(t *testing.T) { | |
| 40 | 49 | bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4} | |
| 41 | 50 | 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 blue | 50 | dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue | ||
M internal/tray/resolve.go +102 -35
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | "unicode" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -12,8 +13,8 @@ import ( | |||
| 12 | 13 | // so a new application needs no table entry: | |
| 13 | 14 | // | |
| 14 | 15 | // 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"; | |
| 17 | 18 | // 4. a glyph for the category of its .desktop file, such as Game; | |
| 18 | 19 | // 5. a letter in a circle. | |
| 19 | 20 | type Resolver struct { | |
| @@ -21,6 +22,7 @@ type Resolver struct { | |||
| 21 | 22 | icons map[string]string // lowercased keys | |
| 22 | 23 | dataDirs []string | |
| 23 | 24 | apps map[string]*desktopApp // nil until the first lookup | |
| 25 | + | loaded time.Time // when apps was read | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | // desktopApp is what the resolver uses from a .desktop file. | |
| @@ -28,6 +30,7 @@ type desktopApp struct { | |||
| 28 | 30 | name string | |
| 29 | 31 | words []string // file name, Icon and StartupWMClass, lowercased | |
| 30 | 32 | categories []string | |
| 33 | + | hidden bool // Hidden=true: the application counts as deleted | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // NewResolver returns a resolver that finds glyphs with lookup (a font's | |
| @@ -57,14 +60,30 @@ var stopwords = map[string]bool{ | |||
| 57 | 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 | 66 | 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"}, | |
| 64 | 70 | {"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 "" | |
| 68 | 87 | } | |
| 69 | 88 | ||
| 70 | 89 | // categories maps .desktop categories to glyphs, most specific first: an | |
| @@ -82,7 +101,8 @@ var categories = [...][2]string{ | |||
| 82 | 101 | ||
| 83 | 102 | // Resolve returns the icon text of an item: one glyph, or the user's | |
| 84 | 103 | // 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 | 106 | func (r *Resolver) Resolve(id, iconName, title string) string { | |
| 87 | 107 | if strings.HasPrefix(iconName, "/") { | |
| 88 | 108 | iconName = "" // a file path names no application | |
| @@ -93,24 +113,19 @@ func (r *Resolver) Resolve(id, iconName, title string) string { | |||
| 93 | 113 | return r.glyphOr(v, v) | |
| 94 | 114 | } | |
| 95 | 115 | } | |
| 96 | - | app := r.app(keys[:]) | |
| 116 | + | app := r.app(id, iconName, title) | |
| 97 | 117 | if app != nil { | |
| 98 | 118 | if v, ok := r.icons[strings.ToLower(app.name)]; ok { | |
| 99 | 119 | return r.glyphOr(v, v) | |
| 100 | 120 | } | |
| 101 | 121 | } | |
| 102 | 122 | // 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 | |
| 110 | 126 | } | |
| 111 | 127 | } | |
| 112 | - | var names []string | |
| 113 | - | names = append(names, keys[:]...) | |
| 128 | + | names := []string{id, iconName} | |
| 114 | 129 | if app != nil { | |
| 115 | 130 | names = append(names, app.words...) | |
| 116 | 131 | names = append(names, app.name) | |
| @@ -120,6 +135,9 @@ func (r *Resolver) Resolve(id, iconName, title string) string { | |||
| 120 | 135 | return g | |
| 121 | 136 | } | |
| 122 | 137 | } | |
| 138 | + | if g, ok := r.prefixed(underscored(title)); ok { | |
| 139 | + | return g | |
| 140 | + | } | |
| 123 | 141 | if app != nil { | |
| 124 | 142 | for _, c := range categories { | |
| 125 | 143 | for _, ac := range app.categories { | |
| @@ -162,16 +180,20 @@ func (r *Resolver) glyphOr(name, fallback string) string { | |||
| 162 | 180 | return fallback | |
| 163 | 181 | } | |
| 164 | 182 | ||
| 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 { | |
| 169 | 187 | if c == '-' || c == '.' || c == ' ' { | |
| 170 | 188 | return '_' | |
| 171 | 189 | } | |
| 172 | - | return c | |
| 190 | + | return unicode.ToLower(c) | |
| 173 | 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 | 197 | if g, ok := r.prefixed(whole); ok { | |
| 176 | 198 | return g, true | |
| 177 | 199 | } | |
| @@ -219,17 +241,37 @@ func firstLetter(s string) rune { | |||
| 219 | 241 | return 0 | |
| 220 | 242 | } | |
| 221 | 243 | ||
| 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 { | |
| 224 | 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 | 270 | if a := r.apps[strings.ToLower(k)]; a != nil && k != "" { | |
| 229 | 271 | return a | |
| 230 | 272 | } | |
| 231 | 273 | } | |
| 232 | - | for _, k := range keys { | |
| 274 | + | for _, k := range [...]string{id, iconName} { | |
| 233 | 275 | for _, w := range words(k) { | |
| 234 | 276 | if a := r.apps[w]; a != nil { | |
| 235 | 277 | return a | |
| @@ -239,6 +281,20 @@ func (r *Resolver) app(keys []string) *desktopApp { | |||
| 239 | 281 | 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 | 298 | func xdgDataDirs() []string { | |
| 243 | 299 | home := os.Getenv("XDG_DATA_HOME") | |
| 244 | 300 | if home == "" { | |
| @@ -254,8 +310,10 @@ func xdgDataDirs() []string { | |||
| 254 | 310 | } | |
| 255 | 311 | ||
| 256 | 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 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. | |
| 259 | 317 | func loadApps(dataDirs []string) map[string]*desktopApp { | |
| 260 | 318 | apps := map[string]*desktopApp{} | |
| 261 | 319 | var all []*desktopApp | |
| @@ -271,7 +329,10 @@ func loadApps(dataDirs []string) map[string]*desktopApp { | |||
| 271 | 329 | } | |
| 272 | 330 | } | |
| 273 | 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 | 336 | apps[k] = a | |
| 276 | 337 | } | |
| 277 | 338 | } | |
| @@ -282,8 +343,8 @@ func loadApps(dataDirs []string) map[string]*desktopApp { | |||
| 282 | 343 | } | |
| 283 | 344 | for _, a := range all { | |
| 284 | 345 | 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) | |
| 287 | 348 | } | |
| 288 | 349 | } | |
| 289 | 350 | } | |
| @@ -327,6 +388,12 @@ func readDesktop(path string) *desktopApp { | |||
| 327 | 388 | } | |
| 328 | 389 | case "Categories": | |
| 329 | 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 | 399 | 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 keys | 22 | icons map[string]string // lowercased keys | ||
| 22 | dataDirs []string | 23 | dataDirs []string | ||
| 23 | apps map[string]*desktopApp // nil until the first lookup | 24 | 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 string | 30 | name string | ||
| 29 | words []string // file name, Icon and StartupWMClass, lowercased | 31 | words []string // file name, Icon and StartupWMClass, lowercased | ||
| 30 | categories []string | 32 | 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's | 36 | // 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: an | 89 | // 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's | 102 | // 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 be | 103 | // 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 application | 108 | 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 []string | 128 | + | 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 g | 135 | 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 fallback | 180 | 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 c | 190 | + | 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, true | 198 | return g, true | ||
| 177 | } | 199 | } | ||
| @@ -219,17 +241,37 @@ func firstLetter(s string) rune { | |||||
| 219 | return 0 | 241 | 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 a | 271 | 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 a | 277 | return a | ||
| @@ -239,6 +281,20 @@ func (r *Resolver) app(keys []string) *desktopApp { | |||||
| 239 | return nil | 281 | 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 over | 313 | + | // 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 []*desktopApp | 319 | 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] = a | 336 | 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 ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // fakeFont knows a few glyph names; each maps to a letter so results read | |
| @@ -17,6 +18,10 @@ var fakeFont = map[string]rune{ | |||
| 17 | 18 | "md-lan": 'N', | |
| 18 | 19 | "md-alpha_z_circle": 'Z', | |
| 19 | 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 | 27 | func lookup(name string) (rune, bool) { | |
| @@ -41,7 +46,13 @@ func TestResolve(t *testing.T) { | |||
| 41 | 46 | writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n") | |
| 42 | 47 | writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n") | |
| 43 | 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 | 57 | for _, c := range []struct { | |
| 47 | 58 | name, id, icon, title, want string | |
| @@ -50,6 +61,15 @@ func TestResolve(t *testing.T) { | |||
| 50 | 61 | {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"}, | |
| 51 | 62 | {"brand from the whole name", "google-chrome", "", "", "C"}, | |
| 52 | 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 | 73 | {"category from the desktop file", "racer", "racer-game", "", "G"}, | |
| 54 | 74 | {"most specific category wins", "chromium", "", "", "W"}, | |
| 55 | 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 | 113 | func TestWords(t *testing.T) { | |
| 70 | 114 | got := words("org.telegram.desktop_2-Tray 42") | |
| 71 | 115 | 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 read | 10 | // 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 string | 58 | 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 { | |||
| 64 | 64 | ||
| 65 | 65 | id, iconName, title string | |
| 66 | 66 | status status | |
| 67 | - | icon string // resolved glyph | |
| 67 | + | icon string // resolved glyph; "" hides the item | |
| 68 | + | resolved bool | |
| 68 | 69 | color RGB | |
| 69 | 70 | hasColor bool | |
| 70 | 71 | loaded bool // GetAll answered at least once | |
| @@ -118,7 +119,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error { | |||
| 118 | 119 | c.Handle = t.dispatch | |
| 119 | 120 | for _, rule := range [...]string{ | |
| 120 | 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 | 124 | "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'", | |
| 123 | 125 | } { | |
| 124 | 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 | 133 | t.claim() | |
| 132 | 134 | if err := t.render(true); err != nil { | |
| 133 | - | return err | |
| 135 | + | return t.exit(ctx, err) | |
| 134 | 136 | } | |
| 135 | 137 | for t.err == nil { | |
| 136 | 138 | m, err := c.ReadMessage() | |
| @@ -139,7 +141,8 @@ func Run(ctx context.Context, c *zerobus.Conn, opt Options) error { | |||
| 139 | 141 | } | |
| 140 | 142 | t.dispatch(m) | |
| 141 | 143 | } | |
| 142 | - | return t.err | |
| 144 | + | // A send fails too when ctx ends during dispatch and closes c. | |
| 145 | + | return t.exit(ctx, t.err) | |
| 143 | 146 | } | |
| 144 | 147 | ||
| 145 | 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 | 209 | switch p.kind { | |
| 207 | 210 | case pendName: | |
| 208 | 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 | 213 | t.becomeWatcher() | |
| 211 | 214 | return | |
| 212 | 215 | } | |
| @@ -217,12 +220,13 @@ func (t *Tray) reply(p pend, m *zerobus.Message) { | |||
| 217 | 220 | return | |
| 218 | 221 | } | |
| 219 | 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 | 225 | return | |
| 222 | 226 | } | |
| 223 | 227 | end := r.Array('s') | |
| 224 | 228 | for r.More(end) { | |
| 225 | - | t.add(r.Str(), "", "") | |
| 229 | + | t.add(r.Str(), "") | |
| 226 | 230 | } | |
| 227 | 231 | case pendGetAll: | |
| 228 | 232 | it := p.it | |
| @@ -230,8 +234,8 @@ func (t *Tray) reply(p pend, m *zerobus.Message) { | |||
| 230 | 234 | return // removed, or an older request | |
| 231 | 235 | } | |
| 232 | 236 | 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) | |
| 235 | 239 | if !it.loaded { | |
| 236 | 240 | t.remove(it) | |
| 237 | 241 | } | |
| @@ -273,11 +277,17 @@ func (t *Tray) signal(m *zerobus.Message) { | |||
| 273 | 277 | return | |
| 274 | 278 | } | |
| 275 | 279 | 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() | |
| 279 | 290 | } | |
| 280 | - | t.vanished(name) | |
| 281 | 291 | case watcherIface: | |
| 282 | 292 | if t.watcher || m.Signature != "s" { | |
| 283 | 293 | return // our own signals come back to us too | |
| @@ -285,13 +295,18 @@ func (t *Tray) signal(m *zerobus.Message) { | |||
| 285 | 295 | s := m.Body().Str() | |
| 286 | 296 | switch m.Member { | |
| 287 | 297 | case "StatusNotifierItemRegistered": | |
| 288 | - | t.add(s, "", "") | |
| 298 | + | t.add(s, "") | |
| 289 | 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 | 302 | t.remove(t.items[i]) | |
| 292 | 303 | } | |
| 293 | 304 | } | |
| 294 | 305 | case itemIface: | |
| 306 | + | switch m.Member { | |
| 307 | + | case "NewToolTip", "NewMenu", "NewOverlayIcon": | |
| 308 | + | return // nothing the tray shows | |
| 309 | + | } | |
| 295 | 310 | for _, it := range t.items { | |
| 296 | 311 | if it.owner == m.Sender && it.path == m.Path { | |
| 297 | 312 | t.refresh(it) | |
| @@ -316,38 +331,61 @@ func (t *Tray) vanished(name string) { | |||
| 316 | 331 | } | |
| 317 | 332 | if name == watcherName && !t.watcher { | |
| 318 | 333 | // 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() | |
| 323 | 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 | 368 | // add starts tracking an item. service is the watcher's form, "bus/path" or | |
| 328 | 369 | // "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 | |
| 341 | 374 | } | |
| 342 | - | dest, _, _ := strings.Cut(service, "/") | |
| 343 | 375 | for _, it := range t.items { | |
| 344 | 376 | if it.service == service || owner != "" && it.owner == owner && it.path == path { | |
| 345 | - | return | |
| 377 | + | return addKnown | |
| 346 | 378 | } | |
| 347 | 379 | } | |
| 348 | 380 | it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)} | |
| 349 | 381 | t.items = append(t.items, it) | |
| 350 | 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 | 391 | func (t *Tray) remove(it *item) { | |
| @@ -422,8 +460,8 @@ func (t *Tray) load(it *item, r *zerobus.Reader) { | |||
| 422 | 460 | if it.hasColor { | |
| 423 | 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 { | |
| 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 | |
| 427 | 465 | } | |
| 428 | 466 | t.renderOrFail() | |
| 429 | 467 | } | |
| @@ -493,20 +531,22 @@ func (t *Tray) call(m *zerobus.Message) { | |||
| 493 | 531 | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service") | |
| 494 | 532 | return | |
| 495 | 533 | } | |
| 496 | - | if !noReply { | |
| 497 | - | t.c.NewReply(m, "") | |
| 498 | - | t.send() | |
| 499 | - | } | |
| 500 | - | var owner, path string | |
| 501 | 534 | 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 | |
| 503 | 536 | } else { | |
| 504 | - | owner, path, s = m.Sender, itemPath, s+itemPath | |
| 537 | + | s += itemPath | |
| 505 | 538 | } | |
| 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, "") | |
| 510 | 550 | t.send() | |
| 511 | 551 | } | |
| 512 | 552 | case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost": | |
| @@ -599,7 +639,7 @@ func (t *Tray) renderOrFail() { | |||
| 599 | 639 | func (t *Tray) render(force bool) error { | |
| 600 | 640 | f := t.frame[:0] | |
| 601 | 641 | for _, it := range t.items { | |
| 602 | - | if !it.loaded || it.status == passive { | |
| 642 | + | if !it.loaded || it.status == passive || it.icon == "" { | |
| 603 | 643 | continue | |
| 604 | 644 | } | |
| 605 | 645 | if len(f) > 0 { | |
| @@ -64,7 +64,8 @@ type item struct { | |||||
| 64 | 64 | ||||
| 65 | id, iconName, title string | 65 | id, iconName, title string | ||
| 66 | status status | 66 | status status | ||
| 67 | - | icon string // resolved glyph | 67 | + | icon string // resolved glyph; "" hides the item |
| 68 | + | resolved bool | |||
| 68 | color RGB | 69 | color RGB | ||
| 69 | hasColor bool | 70 | hasColor bool | ||
| 70 | loaded bool // GetAll answered at least once | 71 | 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.dispatch | 119 | 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 err | 135 | + | 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.err | 144 | + | // 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 | return | 214 | return | ||
| 212 | } | 215 | } | ||
| @@ -217,12 +220,13 @@ func (t *Tray) reply(p pend, m *zerobus.Message) { | |||||
| 217 | return | 220 | 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 | return | 225 | 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.it | 232 | it := p.it | ||
| @@ -230,8 +234,8 @@ func (t *Tray) reply(p pend, m *zerobus.Message) { | |||||
| 230 | return // removed, or an older request | 234 | return // removed, or an older request | ||
| 231 | } | 235 | } | ||
| 232 | it.pending = 0 | 236 | 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 | return | 277 | 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 | - | return | 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() | |||
| 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 too | 293 | 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" or | 368 | // 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 | - | return | 377 | + | 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 | return | 532 | 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 path | 535 | + | s = m.Sender + s // registered by object path |
| 503 | } else { | 536 | } else { | ||
| 504 | - | owner, path, s = m.Sender, itemPath, s+itemPath | 537 | + | 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 | continue | 643 | 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 | |||
| 70 | 70 | 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 | 75 | func (f *fakeItem) run(t *testing.T, fn func()) { | |
| 75 | 76 | t.Helper() | |
| 76 | 77 | 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 | - | 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 { | |
| 266 | 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}) | |
| 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 { | |
| 276 | 318 | return | |
| 277 | 319 | } | |
| 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) | |
| 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 | 367 | out, _, _ := startTray(t, addr) | |
| 304 | 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 | 371 | 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 { | |
| 312 | 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 | 470 | // TestRefreshNoAllocs checks the steady state: an item signals a change, the | |
| @@ -324,7 +477,7 @@ func TestRefreshNoAllocs(t *testing.T) { | |||
| 324 | 477 | tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out}, | |
| 325 | 478 | frame: make([]byte, 0, 256), last: make([]byte, 0, 256)} | |
| 326 | 479 | tr.opt.Log = slog.New(slog.DiscardHandler) | |
| 327 | - | tr.add(app.name, "", "") | |
| 480 | + | tr.add(app.name, "") | |
| 328 | 481 | cycle := func() { | |
| 329 | 482 | tr.refresh(tr.items[0]) | |
| 330 | 483 | for len(tr.pending) > 0 { | |
| @@ -70,7 +70,8 @@ func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeIt | |||||
| 70 | return f | 70 | 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 <- fn | 77 | 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 new | 301 | + | 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 | return | 318 | return | ||
| 277 | } | 319 | } | ||
| 278 | - | if m.Type != zerobus.TypeMethodCall { | 320 | + | (<-w.cmds)() |
| 279 | - | continue | 321 | + | 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 | - | return | 327 | + | 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.name | 372 | + | 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, the | 470 | // 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 { | ||