Merge pull request #2: feat(tray): system tray as Nerd Font icons
15 files · +2469 -13
M README.md +32 -0
| @@ -259,6 +259,38 @@ neferbar app -once title # print once and exit | |||
| 259 | 259 | ||
| 260 | 260 | A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`. | |
| 261 | 261 | ||
| 262 | + | ## System tray | |
| 263 | + | ||
| 264 | + | `neferbar tray` shows the system tray (StatusNotifierItem: Steam, Discord, network and volume applets…) as one Nerd Font icon per application. Add it as a module: | |
| 265 | + | ||
| 266 | + | ```toml | |
| 267 | + | [[module]] | |
| 268 | + | name = "tray" | |
| 269 | + | zone = "right" | |
| 270 | + | exec = '"$NEFERBAR_BIN" tray' | |
| 271 | + | ``` | |
| 272 | + | ||
| 273 | + | Each icon is chosen without a list to maintain, from what the system already knows: | |
| 274 | + | ||
| 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. | |
| 280 | + | ||
| 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. | |
| 282 | + | ||
| 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: | |
| 284 | + | ||
| 285 | + | ```toml | |
| 286 | + | [tray.icons] | |
| 287 | + | sunshine = "md-weather_sunny" | |
| 288 | + | handy = "H" | |
| 289 | + | nm-applet = "" | |
| 290 | + | ``` | |
| 291 | + | ||
| 292 | + | The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it. | |
| 293 | + | ||
| 262 | 294 | ## Test without touching your desktop | |
| 263 | 295 | ||
| 264 | 296 | ```sh | |
| @@ -259,6 +259,38 @@ neferbar app -once title # print once and exit | |||||
| 259 | 259 | ||||
| 260 | A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`. | 260 | A line is empty when no window has the focus. Control characters are removed from every field, so a title cannot move a terminal's cursor. Scripts started by the bar find the binary in `$NEFERBAR_BIN`. | ||
| 261 | 261 | ||||
| 262 | + | ## System tray | |||
| 263 | + | ||||
| 264 | + | `neferbar tray` shows the system tray (StatusNotifierItem: Steam, Discord, network and volume applets…) as one Nerd Font icon per application. Add it as a module: | |||
| 265 | + | ||||
| 266 | + | ```toml | |||
| 267 | + | [[module]] | |||
| 268 | + | name = "tray" | |||
| 269 | + | zone = "right" | |||
| 270 | + | exec = '"$NEFERBAR_BIN" tray' | |||
| 271 | + | ``` | |||
| 272 | + | ||||
| 273 | + | Each icon is chosen without a list to maintain, from what the system already knows: | |||
| 274 | + | ||||
| 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. | |||
| 280 | + | ||||
| 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. | |||
| 282 | + | ||||
| 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: | |||
| 284 | + | ||||
| 285 | + | ```toml | |||
| 286 | + | [tray.icons] | |||
| 287 | + | sunshine = "md-weather_sunny" | |||
| 288 | + | handy = "H" | |||
| 289 | + | nm-applet = "" | |||
| 290 | + | ``` | |||
| 291 | + | ||||
| 292 | + | The tray works next to another panel: when one already runs the tray service, `neferbar tray` reads the same items from it. | |||
| 293 | + | ||||
| 262 | ## Test without touching your desktop | 294 | ## Test without touching your desktop | ||
| 263 | 295 | ||||
| 264 | ```sh | 296 | ```sh | ||
M cmd/neferbar/main.go +8 -3
| @@ -48,12 +48,17 @@ func main() { | |||
| 48 | 48 | } | |
| 49 | 49 | return | |
| 50 | 50 | } | |
| 51 | - | if len(os.Args) > 1 && os.Args[1] == "app" { | |
| 51 | + | if len(os.Args) > 1 && (os.Args[1] == "app" || os.Args[1] == "tray") { | |
| 52 | 52 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 53 | - | err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | |
| 53 | + | var err error | |
| 54 | + | if os.Args[1] == "app" { | |
| 55 | + | err = runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | |
| 56 | + | } else { | |
| 57 | + | err = runTray(ctx, os.Args[2:]) | |
| 58 | + | } | |
| 54 | 59 | stop() | |
| 55 | 60 | if err != nil && !errors.Is(err, flag.ErrHelp) { | |
| 56 | - | fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err) | |
| 61 | + | fmt.Fprintf(os.Stderr, "neferbar %s: %v\n", os.Args[1], err) | |
| 57 | 62 | os.Exit(1) | |
| 58 | 63 | } | |
| 59 | 64 | return | |
| @@ -48,12 +48,17 @@ func main() { | |||||
| 48 | } | 48 | } | ||
| 49 | return | 49 | return | ||
| 50 | } | 50 | } | ||
| 51 | - | if len(os.Args) > 1 && os.Args[1] == "app" { | 51 | + | if len(os.Args) > 1 && (os.Args[1] == "app" || os.Args[1] == "tray") { |
| 52 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | 52 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | ||
| 53 | - | err := runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | 53 | + | var err error |
| 54 | + | if os.Args[1] == "app" { | |||
| 55 | + | err = runApp(ctx, os.Args[2:], os.Getenv("NEFERBAR_DISPLAY")) | |||
| 56 | + | } else { | |||
| 57 | + | err = runTray(ctx, os.Args[2:]) | |||
| 58 | + | } | |||
| 54 | stop() | 59 | stop() | ||
| 55 | if err != nil && !errors.Is(err, flag.ErrHelp) { | 60 | if err != nil && !errors.Is(err, flag.ErrHelp) { | ||
| 56 | - | fmt.Fprintf(os.Stderr, "neferbar app: %v\n", err) | 61 | + | fmt.Fprintf(os.Stderr, "neferbar %s: %v\n", os.Args[1], err) |
| 57 | os.Exit(1) | 62 | os.Exit(1) | ||
| 58 | } | 63 | } | ||
| 59 | return | 64 | return | ||
A cmd/neferbar/tray.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "flag" | |
| 7 | + | "fmt" | |
| 8 | + | "log/slog" | |
| 9 | + | "os" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerobus" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/neferbar/internal/config" | |
| 14 | + | "github.com/bnema/neferbar/internal/glyph" | |
| 15 | + | "github.com/bnema/neferbar/internal/theme" | |
| 16 | + | "github.com/bnema/neferbar/internal/tray" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const trayUsage = `usage: neferbar tray [-config file] | |
| 20 | + | ||
| 21 | + | Prints the system tray as one line of Nerd Font icons: one line now, then a | |
| 22 | + | new one each time an item appears, changes or goes away. Each icon takes the | |
| 23 | + | color of the application's own icon. Hidden (passive) items are left out, and | |
| 24 | + | items that need attention use the accent color. | |
| 25 | + | ||
| 26 | + | Run it as a module: | |
| 27 | + | ||
| 28 | + | [[module]] | |
| 29 | + | name = "tray" | |
| 30 | + | zone = "right" | |
| 31 | + | exec = '"$NEFERBAR_BIN" tray' | |
| 32 | + | ||
| 33 | + | flags: | |
| 34 | + | ` | |
| 35 | + | ||
| 36 | + | // runTray is "neferbar tray": the StatusNotifierItem tray, for a module. | |
| 37 | + | func runTray(ctx context.Context, args []string) error { | |
| 38 | + | fs := flag.NewFlagSet("neferbar tray", flag.ContinueOnError) | |
| 39 | + | cfgPath := fs.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)") | |
| 40 | + | fs.Usage = func() { fmt.Fprint(os.Stderr, trayUsage); fs.PrintDefaults() } | |
| 41 | + | if err := fs.Parse(args); err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | if fs.NArg() > 0 { | |
| 45 | + | fs.Usage() | |
| 46 | + | return fmt.Errorf("unexpected argument %q", fs.Arg(0)) | |
| 47 | + | } | |
| 48 | + | explicit := *cfgPath != "" | |
| 49 | + | if !explicit { | |
| 50 | + | *cfgPath = config.DefaultPath() | |
| 51 | + | } | |
| 52 | + | cfg, err := config.Load(*cfgPath, explicit) | |
| 53 | + | if err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | log := slog.New(slog.NewTextHandler(os.Stderr, nil)) | |
| 57 | + | ||
| 58 | + | // The icons are glyph names of the bar's font. | |
| 59 | + | paths, err := glyph.FindFonts(cfg.Bar.Font) | |
| 60 | + | if err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | names, err := glyph.LoadNames(paths[0]) | |
| 64 | + | if err != nil { | |
| 65 | + | return err | |
| 66 | + | } | |
| 67 | + | defer names.Close() | |
| 68 | + | if names.Len() == 0 { | |
| 69 | + | log.Warn("the font has no icon names: is bar.font a Nerd Font?", "font", cfg.Bar.Font) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | opt := tray.Options{ | |
| 73 | + | Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil), | |
| 74 | + | Out: os.Stdout, | |
| 75 | + | Log: log, | |
| 76 | + | } | |
| 77 | + | opt.Foreground, opt.Background, opt.Accent = trayColors(log) | |
| 78 | + | ||
| 79 | + | c, err := zerobus.SessionBus() | |
| 80 | + | if err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | err = tray.Run(ctx, c, opt) | |
| 84 | + | if errors.Is(err, zerobus.ErrClosed) { | |
| 85 | + | return fmt.Errorf("the session bus closed the connection") | |
| 86 | + | } | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | ||
| 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. | |
| 92 | + | func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) { | |
| 93 | + | def := theme.Default() | |
| 94 | + | return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground), | |
| 95 | + | envColor(log, "NEFERBAR_BACKGROUND", def.Background), | |
| 96 | + | envColor(log, "NEFERBAR_ACCENT", def.Palette[4]) | |
| 97 | + | } | |
| 98 | + | ||
| 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 | |
| 103 | + | } | |
| 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 | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "flag" | |||
| 7 | + | "fmt" | |||
| 8 | + | "log/slog" | |||
| 9 | + | "os" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerobus" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/neferbar/internal/config" | |||
| 14 | + | "github.com/bnema/neferbar/internal/glyph" | |||
| 15 | + | "github.com/bnema/neferbar/internal/theme" | |||
| 16 | + | "github.com/bnema/neferbar/internal/tray" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | const trayUsage = `usage: neferbar tray [-config file] | |||
| 20 | + | ||||
| 21 | + | Prints the system tray as one line of Nerd Font icons: one line now, then a | |||
| 22 | + | new one each time an item appears, changes or goes away. Each icon takes the | |||
| 23 | + | color of the application's own icon. Hidden (passive) items are left out, and | |||
| 24 | + | items that need attention use the accent color. | |||
| 25 | + | ||||
| 26 | + | Run it as a module: | |||
| 27 | + | ||||
| 28 | + | [[module]] | |||
| 29 | + | name = "tray" | |||
| 30 | + | zone = "right" | |||
| 31 | + | exec = '"$NEFERBAR_BIN" tray' | |||
| 32 | + | ||||
| 33 | + | flags: | |||
| 34 | + | ` | |||
| 35 | + | ||||
| 36 | + | // runTray is "neferbar tray": the StatusNotifierItem tray, for a module. | |||
| 37 | + | func runTray(ctx context.Context, args []string) error { | |||
| 38 | + | fs := flag.NewFlagSet("neferbar tray", flag.ContinueOnError) | |||
| 39 | + | cfgPath := fs.String("config", "", "config file (default: $XDG_CONFIG_HOME/neferbar/config.toml)") | |||
| 40 | + | fs.Usage = func() { fmt.Fprint(os.Stderr, trayUsage); fs.PrintDefaults() } | |||
| 41 | + | if err := fs.Parse(args); err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | if fs.NArg() > 0 { | |||
| 45 | + | fs.Usage() | |||
| 46 | + | return fmt.Errorf("unexpected argument %q", fs.Arg(0)) | |||
| 47 | + | } | |||
| 48 | + | explicit := *cfgPath != "" | |||
| 49 | + | if !explicit { | |||
| 50 | + | *cfgPath = config.DefaultPath() | |||
| 51 | + | } | |||
| 52 | + | cfg, err := config.Load(*cfgPath, explicit) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | log := slog.New(slog.NewTextHandler(os.Stderr, nil)) | |||
| 57 | + | ||||
| 58 | + | // The icons are glyph names of the bar's font. | |||
| 59 | + | paths, err := glyph.FindFonts(cfg.Bar.Font) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | names, err := glyph.LoadNames(paths[0]) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return err | |||
| 66 | + | } | |||
| 67 | + | defer names.Close() | |||
| 68 | + | if names.Len() == 0 { | |||
| 69 | + | log.Warn("the font has no icon names: is bar.font a Nerd Font?", "font", cfg.Bar.Font) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | opt := tray.Options{ | |||
| 73 | + | Resolver: tray.NewResolver(names.Lookup, cfg.Tray.Icons, nil), | |||
| 74 | + | Out: os.Stdout, | |||
| 75 | + | Log: log, | |||
| 76 | + | } | |||
| 77 | + | opt.Foreground, opt.Background, opt.Accent = trayColors(log) | |||
| 78 | + | ||||
| 79 | + | c, err := zerobus.SessionBus() | |||
| 80 | + | if err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | err = tray.Run(ctx, c, opt) | |||
| 84 | + | if errors.Is(err, zerobus.ErrClosed) { | |||
| 85 | + | return fmt.Errorf("the session bus closed the connection") | |||
| 86 | + | } | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | ||||
| 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. | |||
| 92 | + | func trayColors(log *slog.Logger) (fg, bg, accent tray.RGB) { | |||
| 93 | + | def := theme.Default() | |||
| 94 | + | return envColor(log, "NEFERBAR_FOREGROUND", def.Foreground), | |||
| 95 | + | envColor(log, "NEFERBAR_BACKGROUND", def.Background), | |||
| 96 | + | envColor(log, "NEFERBAR_ACCENT", def.Palette[4]) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 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 | |||
| 103 | + | } | |||
| 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 | |||
| 110 | + | } | |||
M go.mod +1 -0
| @@ -9,6 +9,7 @@ require ( | |||
| 9 | 9 | github.com/bnema/purego-vulkan v0.6.0 | |
| 10 | 10 | github.com/bnema/vev-vt v0.6.0 | |
| 11 | 11 | github.com/bnema/wlturbo v0.6.2 | |
| 12 | + | github.com/bnema/zerobus v0.1.1 | |
| 12 | 13 | golang.org/x/image v0.46.0 | |
| 13 | 14 | golang.org/x/sys v0.48.0 | |
| 14 | 15 | ) | |
| @@ -9,6 +9,7 @@ require ( | |||||
| 9 | github.com/bnema/purego-vulkan v0.6.0 | 9 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 10 | github.com/bnema/vev-vt v0.6.0 | 10 | github.com/bnema/vev-vt v0.6.0 | ||
| 11 | github.com/bnema/wlturbo v0.6.2 | 11 | github.com/bnema/wlturbo v0.6.2 | ||
| 12 | + | github.com/bnema/zerobus v0.1.1 | |||
| 12 | golang.org/x/image v0.46.0 | 13 | golang.org/x/image v0.46.0 | ||
| 13 | golang.org/x/sys v0.48.0 | 14 | golang.org/x/sys v0.48.0 | ||
| 14 | ) | 15 | ) | ||
M go.sum +2 -0 generated file: collapsed
M internal/config/config.go +17 -0
| @@ -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 | ) | |
| @@ -37,9 +39,18 @@ type Module struct { | |||
| 37 | 39 | Exec string `toml:"exec"` | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 42 | + | // Tray configures "neferbar tray". | |
| 43 | + | type Tray struct { | |
| 44 | + | // Icons replaces the icon chosen for an item. The key is the item id or | |
| 45 | + | // application name, in any case; the value is a Nerd Font glyph name such | |
| 46 | + | // as "fa-steam", or the text to show; "" hides the item. | |
| 47 | + | Icons map[string]string `toml:"icons"` | |
| 48 | + | } | |
| 49 | + | ||
| 40 | 50 | // Config is the whole file. | |
| 41 | 51 | type Config struct { | |
| 42 | 52 | Bar Bar `toml:"bar"` | |
| 53 | + | Tray Tray `toml:"tray"` | |
| 43 | 54 | Module []Module `toml:"module"` | |
| 44 | 55 | } | |
| 45 | 56 | ||
| @@ -126,6 +137,12 @@ func (c *Config) Validate() error { | |||
| 126 | 137 | return fmt.Errorf("config: module %q: zone must be left, center or right", m.Name) | |
| 127 | 138 | } | |
| 128 | 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.%q contains a control character", k) | |
| 144 | + | } | |
| 145 | + | } | |
| 129 | 146 | return nil | |
| 130 | 147 | } | |
| 131 | 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 | ) | ||
| @@ -37,9 +39,18 @@ type Module struct { | |||||
| 37 | Exec string `toml:"exec"` | 39 | Exec string `toml:"exec"` | ||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 42 | + | // Tray configures "neferbar tray". | |||
| 43 | + | type Tray struct { | |||
| 44 | + | // Icons replaces the icon chosen for an item. The key is the item id or | |||
| 45 | + | // application name, in any case; the value is a Nerd Font glyph name such | |||
| 46 | + | // as "fa-steam", or the text to show; "" hides the item. | |||
| 47 | + | Icons map[string]string `toml:"icons"` | |||
| 48 | + | } | |||
| 49 | + | ||||
| 40 | // Config is the whole file. | 50 | // Config is the whole file. | ||
| 41 | type Config struct { | 51 | type Config struct { | ||
| 42 | Bar Bar `toml:"bar"` | 52 | Bar Bar `toml:"bar"` | ||
| 53 | + | Tray Tray `toml:"tray"` | |||
| 43 | Module []Module `toml:"module"` | 54 | Module []Module `toml:"module"` | ||
| 44 | } | 55 | } | ||
| 45 | 56 | ||||
| @@ -126,6 +137,12 @@ func (c *Config) Validate() error { | |||||
| 126 | 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) | ||
| 127 | } | 138 | } | ||
| 128 | } | 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.%q contains a control character", k) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 129 | return nil | 146 | return nil | ||
| 130 | } | 147 | } | ||
| 131 | 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) | ||
A internal/glyph/names.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package glyph | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "fmt" | |
| 7 | + | "slices" | |
| 8 | + | ||
| 9 | + | "golang.org/x/image/font/sfnt" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Names maps the glyph names stored in a font file to the runes that draw | |
| 14 | + | // them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use | |
| 15 | + | // areas are indexed: that is where icon fonts put their glyphs. | |
| 16 | + | // | |
| 17 | + | // The names stay in the mapped font file; a lookup does not allocate. | |
| 18 | + | type Names struct { | |
| 19 | + | data []byte | |
| 20 | + | idx []nameEntry // sorted by name | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type nameEntry struct { | |
| 24 | + | off, n uint32 // the name is data[off:off+n] | |
| 25 | + | r rune | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // iconRanges are the private use areas: the BMP one and plane 15. | |
| 29 | + | var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}} | |
| 30 | + | ||
| 31 | + | // LoadNames indexes the glyph names of the font file at path. A font without | |
| 32 | + | // glyph names (a version 3 "post" table) gives an empty index, not an error. | |
| 33 | + | func LoadNames(path string) (*Names, error) { | |
| 34 | + | data, err := mapFile(path) | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, fmt.Errorf("glyph: %w", err) | |
| 37 | + | } | |
| 38 | + | n := &Names{data: data} | |
| 39 | + | f, err := sfnt.Parse(data) | |
| 40 | + | if err != nil { | |
| 41 | + | n.Close() | |
| 42 | + | return nil, fmt.Errorf("glyph: parse %s: %w", path, err) | |
| 43 | + | } | |
| 44 | + | spans := postNames(data) | |
| 45 | + | var buf sfnt.Buffer | |
| 46 | + | for _, rg := range iconRanges { | |
| 47 | + | for r := rg[0]; r <= rg[1]; r++ { | |
| 48 | + | gi, err := f.GlyphIndex(&buf, r) | |
| 49 | + | if err != nil || gi == 0 || int(gi) >= len(spans) { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | if s := spans[gi]; s.n > 0 { | |
| 53 | + | n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r}) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | slices.SortFunc(n.idx, func(a, b nameEntry) int { | |
| 58 | + | return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n]) | |
| 59 | + | }) | |
| 60 | + | return n, nil | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Lookup returns the rune of the glyph called name. | |
| 64 | + | func (n *Names) Lookup(name string) (rune, bool) { | |
| 65 | + | if n == nil { | |
| 66 | + | return 0, false | |
| 67 | + | } | |
| 68 | + | lo, hi := 0, len(n.idx) | |
| 69 | + | for lo < hi { | |
| 70 | + | m := int(uint(lo+hi) >> 1) | |
| 71 | + | e := n.idx[m] | |
| 72 | + | // string(bytes) in a comparison does not allocate. | |
| 73 | + | switch s := n.data[e.off : e.off+e.n]; { | |
| 74 | + | case string(s) < name: | |
| 75 | + | lo = m + 1 | |
| 76 | + | case string(s) > name: | |
| 77 | + | hi = m | |
| 78 | + | default: | |
| 79 | + | return e.r, true | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return 0, false | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Len returns the number of indexed names. | |
| 86 | + | func (n *Names) Len() int { return len(n.idx) } | |
| 87 | + | ||
| 88 | + | // Close unmaps the font file. n must not be used afterwards. | |
| 89 | + | func (n *Names) Close() { | |
| 90 | + | if n != nil && n.data != nil { | |
| 91 | + | _ = unix.Munmap(n.data) | |
| 92 | + | n.data, n.idx = nil, nil | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | type span struct{ off, n uint32 } | |
| 97 | + | ||
| 98 | + | // postNames returns, for each glyph index, where its custom name sits in | |
| 99 | + | // data, from a version 2 "post" table. Glyphs with a standard Macintosh name | |
| 100 | + | // (index < 258) or no table at all give empty spans. Every offset is checked | |
| 101 | + | // against the file, so a damaged font gives fewer names, never a panic. | |
| 102 | + | func postNames(data []byte) []span { | |
| 103 | + | u16 := func(off int) (int, bool) { | |
| 104 | + | if off < 0 || off+2 > len(data) { | |
| 105 | + | return 0, false | |
| 106 | + | } | |
| 107 | + | return int(binary.BigEndian.Uint16(data[off:])), true | |
| 108 | + | } | |
| 109 | + | u32 := func(off int) (uint32, bool) { | |
| 110 | + | if off < 0 || off+4 > len(data) { | |
| 111 | + | return 0, false | |
| 112 | + | } | |
| 113 | + | return binary.BigEndian.Uint32(data[off:]), true | |
| 114 | + | } | |
| 115 | + | tables, ok := u16(4) | |
| 116 | + | if !ok { | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | post, end := -1, 0 | |
| 120 | + | for i := range tables { | |
| 121 | + | rec := 12 + 16*i | |
| 122 | + | if rec+16 > len(data) { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | if string(data[rec:rec+4]) == "post" { | |
| 126 | + | off, _ := u32(rec + 8) | |
| 127 | + | n, _ := u32(rec + 12) | |
| 128 | + | if uint64(off)+uint64(n) > uint64(len(data)) { | |
| 129 | + | return nil | |
| 130 | + | } | |
| 131 | + | post, end = int(off), int(off)+int(n) | |
| 132 | + | break | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if v, ok := u32(post); !ok || v != 0x00020000 { | |
| 136 | + | return nil | |
| 137 | + | } | |
| 138 | + | glyphs, ok := u16(post + 32) | |
| 139 | + | if !ok || post+34+2*glyphs > end { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | // The custom names follow the index array as Pascal strings. | |
| 143 | + | var custom []uint32 | |
| 144 | + | for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) { | |
| 145 | + | if o+1+int(data[o]) > end { | |
| 146 | + | break | |
| 147 | + | } | |
| 148 | + | custom = append(custom, uint32(o)) | |
| 149 | + | } | |
| 150 | + | spans := make([]span, glyphs) | |
| 151 | + | for g := range glyphs { | |
| 152 | + | k, _ := u16(post + 34 + 2*g) | |
| 153 | + | if k < 258 || k-258 >= len(custom) { | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | o := custom[k-258] | |
| 157 | + | spans[g] = span{off: o + 1, n: uint32(data[o])} | |
| 158 | + | } | |
| 159 | + | return spans | |
| 160 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package glyph | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "fmt" | |||
| 7 | + | "slices" | |||
| 8 | + | ||||
| 9 | + | "golang.org/x/image/font/sfnt" | |||
| 10 | + | "golang.org/x/sys/unix" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Names maps the glyph names stored in a font file to the runes that draw | |||
| 14 | + | // them, such as "fa-steam" to U+F1B6 in a Nerd Font. Only the private use | |||
| 15 | + | // areas are indexed: that is where icon fonts put their glyphs. | |||
| 16 | + | // | |||
| 17 | + | // The names stay in the mapped font file; a lookup does not allocate. | |||
| 18 | + | type Names struct { | |||
| 19 | + | data []byte | |||
| 20 | + | idx []nameEntry // sorted by name | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type nameEntry struct { | |||
| 24 | + | off, n uint32 // the name is data[off:off+n] | |||
| 25 | + | r rune | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // iconRanges are the private use areas: the BMP one and plane 15. | |||
| 29 | + | var iconRanges = [...][2]rune{{0xE000, 0xF8FF}, {0xF0000, 0xFFFFD}} | |||
| 30 | + | ||||
| 31 | + | // LoadNames indexes the glyph names of the font file at path. A font without | |||
| 32 | + | // glyph names (a version 3 "post" table) gives an empty index, not an error. | |||
| 33 | + | func LoadNames(path string) (*Names, error) { | |||
| 34 | + | data, err := mapFile(path) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return nil, fmt.Errorf("glyph: %w", err) | |||
| 37 | + | } | |||
| 38 | + | n := &Names{data: data} | |||
| 39 | + | f, err := sfnt.Parse(data) | |||
| 40 | + | if err != nil { | |||
| 41 | + | n.Close() | |||
| 42 | + | return nil, fmt.Errorf("glyph: parse %s: %w", path, err) | |||
| 43 | + | } | |||
| 44 | + | spans := postNames(data) | |||
| 45 | + | var buf sfnt.Buffer | |||
| 46 | + | for _, rg := range iconRanges { | |||
| 47 | + | for r := rg[0]; r <= rg[1]; r++ { | |||
| 48 | + | gi, err := f.GlyphIndex(&buf, r) | |||
| 49 | + | if err != nil || gi == 0 || int(gi) >= len(spans) { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | if s := spans[gi]; s.n > 0 { | |||
| 53 | + | n.idx = append(n.idx, nameEntry{off: s.off, n: s.n, r: r}) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | slices.SortFunc(n.idx, func(a, b nameEntry) int { | |||
| 58 | + | return bytes.Compare(n.data[a.off:a.off+a.n], n.data[b.off:b.off+b.n]) | |||
| 59 | + | }) | |||
| 60 | + | return n, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Lookup returns the rune of the glyph called name. | |||
| 64 | + | func (n *Names) Lookup(name string) (rune, bool) { | |||
| 65 | + | if n == nil { | |||
| 66 | + | return 0, false | |||
| 67 | + | } | |||
| 68 | + | lo, hi := 0, len(n.idx) | |||
| 69 | + | for lo < hi { | |||
| 70 | + | m := int(uint(lo+hi) >> 1) | |||
| 71 | + | e := n.idx[m] | |||
| 72 | + | // string(bytes) in a comparison does not allocate. | |||
| 73 | + | switch s := n.data[e.off : e.off+e.n]; { | |||
| 74 | + | case string(s) < name: | |||
| 75 | + | lo = m + 1 | |||
| 76 | + | case string(s) > name: | |||
| 77 | + | hi = m | |||
| 78 | + | default: | |||
| 79 | + | return e.r, true | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return 0, false | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Len returns the number of indexed names. | |||
| 86 | + | func (n *Names) Len() int { return len(n.idx) } | |||
| 87 | + | ||||
| 88 | + | // Close unmaps the font file. n must not be used afterwards. | |||
| 89 | + | func (n *Names) Close() { | |||
| 90 | + | if n != nil && n.data != nil { | |||
| 91 | + | _ = unix.Munmap(n.data) | |||
| 92 | + | n.data, n.idx = nil, nil | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | type span struct{ off, n uint32 } | |||
| 97 | + | ||||
| 98 | + | // postNames returns, for each glyph index, where its custom name sits in | |||
| 99 | + | // data, from a version 2 "post" table. Glyphs with a standard Macintosh name | |||
| 100 | + | // (index < 258) or no table at all give empty spans. Every offset is checked | |||
| 101 | + | // against the file, so a damaged font gives fewer names, never a panic. | |||
| 102 | + | func postNames(data []byte) []span { | |||
| 103 | + | u16 := func(off int) (int, bool) { | |||
| 104 | + | if off < 0 || off+2 > len(data) { | |||
| 105 | + | return 0, false | |||
| 106 | + | } | |||
| 107 | + | return int(binary.BigEndian.Uint16(data[off:])), true | |||
| 108 | + | } | |||
| 109 | + | u32 := func(off int) (uint32, bool) { | |||
| 110 | + | if off < 0 || off+4 > len(data) { | |||
| 111 | + | return 0, false | |||
| 112 | + | } | |||
| 113 | + | return binary.BigEndian.Uint32(data[off:]), true | |||
| 114 | + | } | |||
| 115 | + | tables, ok := u16(4) | |||
| 116 | + | if !ok { | |||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | post, end := -1, 0 | |||
| 120 | + | for i := range tables { | |||
| 121 | + | rec := 12 + 16*i | |||
| 122 | + | if rec+16 > len(data) { | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | if string(data[rec:rec+4]) == "post" { | |||
| 126 | + | off, _ := u32(rec + 8) | |||
| 127 | + | n, _ := u32(rec + 12) | |||
| 128 | + | if uint64(off)+uint64(n) > uint64(len(data)) { | |||
| 129 | + | return nil | |||
| 130 | + | } | |||
| 131 | + | post, end = int(off), int(off)+int(n) | |||
| 132 | + | break | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | if v, ok := u32(post); !ok || v != 0x00020000 { | |||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | glyphs, ok := u16(post + 32) | |||
| 139 | + | if !ok || post+34+2*glyphs > end { | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | // The custom names follow the index array as Pascal strings. | |||
| 143 | + | var custom []uint32 | |||
| 144 | + | for o := post + 34 + 2*glyphs; o < end; o += 1 + int(data[o]) { | |||
| 145 | + | if o+1+int(data[o]) > end { | |||
| 146 | + | break | |||
| 147 | + | } | |||
| 148 | + | custom = append(custom, uint32(o)) | |||
| 149 | + | } | |||
| 150 | + | spans := make([]span, glyphs) | |||
| 151 | + | for g := range glyphs { | |||
| 152 | + | k, _ := u16(post + 34 + 2*g) | |||
| 153 | + | if k < 258 || k-258 >= len(custom) { | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | o := custom[k-258] | |||
| 157 | + | spans[g] = span{off: o + 1, n: uint32(data[o])} | |||
| 158 | + | } | |||
| 159 | + | return spans | |||
| 160 | + | } | |||
A internal/glyph/names_test.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package glyph | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // nerdFont returns the path of an installed Nerd Font, or skips the test. | |
| 11 | + | func nerdFont(t *testing.T) string { | |
| 12 | + | t.Helper() | |
| 13 | + | paths, err := FindFonts("JetBrainsMono Nerd Font Mono") | |
| 14 | + | if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") { | |
| 15 | + | t.Skip("no Nerd Font installed") | |
| 16 | + | } | |
| 17 | + | return paths[0] | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestNamesLookup(t *testing.T) { | |
| 21 | + | n, err := LoadNames(nerdFont(t)) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | defer n.Close() | |
| 26 | + | if n.Len() < 1000 { | |
| 27 | + | t.Fatalf("only %d names indexed", n.Len()) | |
| 28 | + | } | |
| 29 | + | for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} { | |
| 30 | + | if r, ok := n.Lookup(name); !ok || r != want { | |
| 31 | + | t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | if _, ok := n.Lookup("no-such-glyph"); ok { | |
| 35 | + | t.Error("an unknown name was found") | |
| 36 | + | } | |
| 37 | + | if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 { | |
| 38 | + | t.Errorf("Lookup allocates %v times", allocs) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 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 | + | } | |
| 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) | |
| 58 | + | } | |
| 59 | + | for _, s := range names { | |
| 60 | + | post = append(post, byte(len(s))) | |
| 61 | + | post = append(post, s...) | |
| 62 | + | } | |
| 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)) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestPostNamesDamagedFont(t *testing.T) { | |
| 96 | + | // Truncated or garbage files give no names and never panic. | |
| 97 | + | var good []byte | |
| 98 | + | if paths, err := FindFonts("monospace"); err == nil { | |
| 99 | + | good, _ = os.ReadFile(paths[0]) | |
| 100 | + | } | |
| 101 | + | inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)} | |
| 102 | + | for _, cut := range []int{12, 100, 1000, len(good) / 2} { | |
| 103 | + | if cut <= len(good) { | |
| 104 | + | inputs = append(inputs, good[:cut]) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | for _, b := range inputs { | |
| 108 | + | _ = postNames(b) | |
| 109 | + | } | |
| 110 | + | // A "post" table that claims more glyphs than it holds. | |
| 111 | + | b := postFont(0x00020000, nil) | |
| 112 | + | binary.BigEndian.PutUint16(b[28+32:], 0xff) | |
| 113 | + | if spans := postNames(b); spans != nil { | |
| 114 | + | t.Fatalf("got %d spans from a bad table", len(spans)) | |
| 115 | + | } | |
| 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 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | package glyph | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // nerdFont returns the path of an installed Nerd Font, or skips the test. | |||
| 11 | + | func nerdFont(t *testing.T) string { | |||
| 12 | + | t.Helper() | |||
| 13 | + | paths, err := FindFonts("JetBrainsMono Nerd Font Mono") | |||
| 14 | + | if err != nil || !strings.Contains(strings.ToLower(paths[0]), "nerd") { | |||
| 15 | + | t.Skip("no Nerd Font installed") | |||
| 16 | + | } | |||
| 17 | + | return paths[0] | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestNamesLookup(t *testing.T) { | |||
| 21 | + | n, err := LoadNames(nerdFont(t)) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | defer n.Close() | |||
| 26 | + | if n.Len() < 1000 { | |||
| 27 | + | t.Fatalf("only %d names indexed", n.Len()) | |||
| 28 | + | } | |||
| 29 | + | for name, want := range map[string]rune{"fa-steam": 0xF1B6, "md-steam": 0xF04D3} { | |||
| 30 | + | if r, ok := n.Lookup(name); !ok || r != want { | |||
| 31 | + | t.Errorf("Lookup(%q) = %U, %v; want %U", name, r, ok, want) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | if _, ok := n.Lookup("no-such-glyph"); ok { | |||
| 35 | + | t.Error("an unknown name was found") | |||
| 36 | + | } | |||
| 37 | + | if allocs := testing.AllocsPerRun(100, func() { n.Lookup("fa-steam") }); allocs != 0 { | |||
| 38 | + | t.Errorf("Lookup allocates %v times", allocs) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 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 | + | } | |||
| 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) | |||
| 58 | + | } | |||
| 59 | + | for _, s := range names { | |||
| 60 | + | post = append(post, byte(len(s))) | |||
| 61 | + | post = append(post, s...) | |||
| 62 | + | } | |||
| 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)) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestPostNamesDamagedFont(t *testing.T) { | |||
| 96 | + | // Truncated or garbage files give no names and never panic. | |||
| 97 | + | var good []byte | |||
| 98 | + | if paths, err := FindFonts("monospace"); err == nil { | |||
| 99 | + | good, _ = os.ReadFile(paths[0]) | |||
| 100 | + | } | |||
| 101 | + | inputs := [][]byte{nil, {0, 1, 0, 0}, make([]byte, 64)} | |||
| 102 | + | for _, cut := range []int{12, 100, 1000, len(good) / 2} { | |||
| 103 | + | if cut <= len(good) { | |||
| 104 | + | inputs = append(inputs, good[:cut]) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | for _, b := range inputs { | |||
| 108 | + | _ = postNames(b) | |||
| 109 | + | } | |||
| 110 | + | // A "post" table that claims more glyphs than it holds. | |||
| 111 | + | b := postFont(0x00020000, nil) | |||
| 112 | + | binary.BigEndian.PutUint16(b[28+32:], 0xff) | |||
| 113 | + | if spans := postNames(b); spans != nil { | |||
| 114 | + | t.Fatalf("got %d spans from a bad table", len(spans)) | |||
| 115 | + | } | |||
| 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 | + | } | |||
A internal/tray/color.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 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 | |
| 13 | + | ||
| 14 | + | // histogram finds the dominant color of an icon. Colors are counted in 4096 | |
| 15 | + | // buckets (4 bits per channel), weighted by opacity and saturation so a logo's | |
| 16 | + | // color wins over its grey outline. It is reused between icons: no | |
| 17 | + | // allocation. | |
| 18 | + | type histogram struct { | |
| 19 | + | weight [4096]uint32 | |
| 20 | + | sum [4096][3]uint32 | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // dominant returns the dominant color of argb, an SNI pixmap: big-endian | |
| 24 | + | // ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully | |
| 25 | + | // transparent or grey. Large icons are sampled evenly. | |
| 26 | + | func (h *histogram) dominant(argb []byte) (RGB, bool) { | |
| 27 | + | if len(argb) < 4 { | |
| 28 | + | return RGB{}, false | |
| 29 | + | } | |
| 30 | + | clear(h.weight[:]) | |
| 31 | + | clear(h.sum[:]) | |
| 32 | + | // An odd pixel step crosses every column of a power-of-two wide icon, | |
| 33 | + | // so samples do not line up in a few columns. | |
| 34 | + | step := (len(argb)/4 + maxSamples - 1) / maxSamples | |
| 35 | + | if step > 1 { | |
| 36 | + | step |= 1 | |
| 37 | + | } | |
| 38 | + | for i := 0; i+3 < len(argb); i += 4 * step { | |
| 39 | + | a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3]) | |
| 40 | + | if a < 128 { | |
| 41 | + | continue | |
| 42 | + | } | |
| 43 | + | hi, lo := max(r, g, b), min(r, g, b) | |
| 44 | + | chroma := hi - lo | |
| 45 | + | if chroma < 40 { | |
| 46 | + | continue // grey, black or white: outline and shading | |
| 47 | + | } | |
| 48 | + | w := a * chroma >> 8 | |
| 49 | + | k := (r>>4)<<8 | (g>>4)<<4 | b>>4 | |
| 50 | + | h.weight[k] += w | |
| 51 | + | h.sum[k][0] += r * w | |
| 52 | + | h.sum[k][1] += g * w | |
| 53 | + | h.sum[k][2] += b * w | |
| 54 | + | } | |
| 55 | + | best := -1 | |
| 56 | + | for k, w := range h.weight { | |
| 57 | + | if w > 0 && (best < 0 || w > h.weight[best]) { | |
| 58 | + | best = k | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | if best < 0 { | |
| 62 | + | return RGB{}, false | |
| 63 | + | } | |
| 64 | + | w := h.weight[best] | |
| 65 | + | s := h.sum[best] | |
| 66 | + | return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // readable moves c toward fg until it has enough contrast with bg, so a dark | |
| 70 | + | // logo still shows on a dark bar. | |
| 71 | + | func readable(c, bg, fg RGB) RGB { | |
| 72 | + | const minContrast = 3.0 // WCAG's minimum for large text and icons | |
| 73 | + | for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ { | |
| 74 | + | c = theme.Mix(c, fg, 0.25) | |
| 75 | + | } | |
| 76 | + | return c | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // contrast is the WCAG contrast ratio of two colors, from 1 to 21. | |
| 80 | + | func contrast(a, b RGB) float64 { | |
| 81 | + | la, lb := luminance(a), luminance(b) | |
| 82 | + | if la < lb { | |
| 83 | + | la, lb = lb, la | |
| 84 | + | } | |
| 85 | + | return (la + 0.05) / (lb + 0.05) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func luminance(c RGB) float64 { | |
| 89 | + | lin := func(v uint8) float64 { | |
| 90 | + | x := float64(v) / 255 | |
| 91 | + | if x <= 0.04045 { | |
| 92 | + | return x / 12.92 | |
| 93 | + | } | |
| 94 | + | return math.Pow((x+0.055)/1.055, 2.4) | |
| 95 | + | } | |
| 96 | + | return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2]) | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 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 | |||
| 13 | + | ||||
| 14 | + | // histogram finds the dominant color of an icon. Colors are counted in 4096 | |||
| 15 | + | // buckets (4 bits per channel), weighted by opacity and saturation so a logo's | |||
| 16 | + | // color wins over its grey outline. It is reused between icons: no | |||
| 17 | + | // allocation. | |||
| 18 | + | type histogram struct { | |||
| 19 | + | weight [4096]uint32 | |||
| 20 | + | sum [4096][3]uint32 | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // dominant returns the dominant color of argb, an SNI pixmap: big-endian | |||
| 24 | + | // ARGB32, 4 bytes per pixel. It reports false when the icon is empty, fully | |||
| 25 | + | // transparent or grey. Large icons are sampled evenly. | |||
| 26 | + | func (h *histogram) dominant(argb []byte) (RGB, bool) { | |||
| 27 | + | if len(argb) < 4 { | |||
| 28 | + | return RGB{}, false | |||
| 29 | + | } | |||
| 30 | + | clear(h.weight[:]) | |||
| 31 | + | clear(h.sum[:]) | |||
| 32 | + | // An odd pixel step crosses every column of a power-of-two wide icon, | |||
| 33 | + | // so samples do not line up in a few columns. | |||
| 34 | + | step := (len(argb)/4 + maxSamples - 1) / maxSamples | |||
| 35 | + | if step > 1 { | |||
| 36 | + | step |= 1 | |||
| 37 | + | } | |||
| 38 | + | for i := 0; i+3 < len(argb); i += 4 * step { | |||
| 39 | + | a, r, g, b := uint32(argb[i]), uint32(argb[i+1]), uint32(argb[i+2]), uint32(argb[i+3]) | |||
| 40 | + | if a < 128 { | |||
| 41 | + | continue | |||
| 42 | + | } | |||
| 43 | + | hi, lo := max(r, g, b), min(r, g, b) | |||
| 44 | + | chroma := hi - lo | |||
| 45 | + | if chroma < 40 { | |||
| 46 | + | continue // grey, black or white: outline and shading | |||
| 47 | + | } | |||
| 48 | + | w := a * chroma >> 8 | |||
| 49 | + | k := (r>>4)<<8 | (g>>4)<<4 | b>>4 | |||
| 50 | + | h.weight[k] += w | |||
| 51 | + | h.sum[k][0] += r * w | |||
| 52 | + | h.sum[k][1] += g * w | |||
| 53 | + | h.sum[k][2] += b * w | |||
| 54 | + | } | |||
| 55 | + | best := -1 | |||
| 56 | + | for k, w := range h.weight { | |||
| 57 | + | if w > 0 && (best < 0 || w > h.weight[best]) { | |||
| 58 | + | best = k | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | if best < 0 { | |||
| 62 | + | return RGB{}, false | |||
| 63 | + | } | |||
| 64 | + | w := h.weight[best] | |||
| 65 | + | s := h.sum[best] | |||
| 66 | + | return RGB{uint8(s[0] / w), uint8(s[1] / w), uint8(s[2] / w)}, true | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // readable moves c toward fg until it has enough contrast with bg, so a dark | |||
| 70 | + | // logo still shows on a dark bar. | |||
| 71 | + | func readable(c, bg, fg RGB) RGB { | |||
| 72 | + | const minContrast = 3.0 // WCAG's minimum for large text and icons | |||
| 73 | + | for i := 0; i < 10 && contrast(c, bg) < minContrast; i++ { | |||
| 74 | + | c = theme.Mix(c, fg, 0.25) | |||
| 75 | + | } | |||
| 76 | + | return c | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // contrast is the WCAG contrast ratio of two colors, from 1 to 21. | |||
| 80 | + | func contrast(a, b RGB) float64 { | |||
| 81 | + | la, lb := luminance(a), luminance(b) | |||
| 82 | + | if la < lb { | |||
| 83 | + | la, lb = lb, la | |||
| 84 | + | } | |||
| 85 | + | return (la + 0.05) / (lb + 0.05) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func luminance(c RGB) float64 { | |||
| 89 | + | lin := func(v uint8) float64 { | |||
| 90 | + | x := float64(v) / 255 | |||
| 91 | + | if x <= 0.04045 { | |||
| 92 | + | return x / 12.92 | |||
| 93 | + | } | |||
| 94 | + | return math.Pow((x+0.055)/1.055, 2.4) | |||
| 95 | + | } | |||
| 96 | + | return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2]) | |||
| 97 | + | } | |||
A internal/tray/color_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // pixmap builds an ARGB32 icon from pixels of the given colors, n of each. | |
| 6 | + | func pixmap(colors map[[4]uint8]int) []byte { | |
| 7 | + | var b []byte | |
| 8 | + | for c, n := range colors { | |
| 9 | + | for range n { | |
| 10 | + | b = append(b, c[:]...) | |
| 11 | + | } | |
| 12 | + | } | |
| 13 | + | return b | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestDominant(t *testing.T) { | |
| 17 | + | var h histogram | |
| 18 | + | // A blue logo with a larger grey outline and a transparent background. | |
| 19 | + | got, ok := h.dominant(pixmap(map[[4]uint8]int{ | |
| 20 | + | {255, 30, 60, 200}: 40, // blue | |
| 21 | + | {255, 128, 128, 128}: 100, | |
| 22 | + | {0, 255, 0, 0}: 500, // transparent red | |
| 23 | + | })) | |
| 24 | + | if !ok || got != (RGB{30, 60, 200}) { | |
| 25 | + | t.Fatalf("dominant = %v, %v", got, ok) | |
| 26 | + | } | |
| 27 | + | if _, ok := h.dominant(pixmap(map[[4]uint8]int{{255, 200, 200, 200}: 10})); ok { | |
| 28 | + | t.Fatal("a grey icon has no dominant color") | |
| 29 | + | } | |
| 30 | + | if _, ok := h.dominant(nil); ok { | |
| 31 | + | t.Fatal("an empty icon has no dominant color") | |
| 32 | + | } | |
| 33 | + | icon := pixmap(map[[4]uint8]int{{255, 30, 60, 200}: 1024}) | |
| 34 | + | if allocs := testing.AllocsPerRun(50, func() { h.dominant(icon) }); allocs != 0 { | |
| 35 | + | t.Fatalf("dominant allocates %v times", allocs) | |
| 36 | + | } | |
| 37 | + | } | |
| 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 | + | ||
| 48 | + | func TestDominantLargeIconOffColumns(t *testing.T) { | |
| 49 | + | // 512x512, transparent but for a red logo in columns 10..49: a stride of | |
| 50 | + | // 64 pixels would sample only columns 0, 64, 128... and miss it. | |
| 51 | + | const side = 512 | |
| 52 | + | icon := make([]byte, 0, side*side*4) | |
| 53 | + | for range side { | |
| 54 | + | for x := range side { | |
| 55 | + | if x >= 10 && x < 50 { | |
| 56 | + | icon = append(icon, 255, 250, 20, 20) | |
| 57 | + | } else { | |
| 58 | + | icon = append(icon, 0, 0, 0, 0) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | var h histogram | |
| 63 | + | if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) { | |
| 64 | + | t.Fatalf("dominant = %v, %v", got, ok) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestReadable(t *testing.T) { | |
| 69 | + | bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4} | |
| 70 | + | dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue | |
| 71 | + | got := readable(dark, bg, fg) | |
| 72 | + | if contrast(got, bg) < 3 { | |
| 73 | + | t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg)) | |
| 74 | + | } | |
| 75 | + | bright := RGB{0xf9, 0xe2, 0xaf} | |
| 76 | + | if readable(bright, bg, fg) != bright { | |
| 77 | + | t.Fatal("a readable color must not change") | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // pixmap builds an ARGB32 icon from pixels of the given colors, n of each. | |||
| 6 | + | func pixmap(colors map[[4]uint8]int) []byte { | |||
| 7 | + | var b []byte | |||
| 8 | + | for c, n := range colors { | |||
| 9 | + | for range n { | |||
| 10 | + | b = append(b, c[:]...) | |||
| 11 | + | } | |||
| 12 | + | } | |||
| 13 | + | return b | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestDominant(t *testing.T) { | |||
| 17 | + | var h histogram | |||
| 18 | + | // A blue logo with a larger grey outline and a transparent background. | |||
| 19 | + | got, ok := h.dominant(pixmap(map[[4]uint8]int{ | |||
| 20 | + | {255, 30, 60, 200}: 40, // blue | |||
| 21 | + | {255, 128, 128, 128}: 100, | |||
| 22 | + | {0, 255, 0, 0}: 500, // transparent red | |||
| 23 | + | })) | |||
| 24 | + | if !ok || got != (RGB{30, 60, 200}) { | |||
| 25 | + | t.Fatalf("dominant = %v, %v", got, ok) | |||
| 26 | + | } | |||
| 27 | + | if _, ok := h.dominant(pixmap(map[[4]uint8]int{{255, 200, 200, 200}: 10})); ok { | |||
| 28 | + | t.Fatal("a grey icon has no dominant color") | |||
| 29 | + | } | |||
| 30 | + | if _, ok := h.dominant(nil); ok { | |||
| 31 | + | t.Fatal("an empty icon has no dominant color") | |||
| 32 | + | } | |||
| 33 | + | icon := pixmap(map[[4]uint8]int{{255, 30, 60, 200}: 1024}) | |||
| 34 | + | if allocs := testing.AllocsPerRun(50, func() { h.dominant(icon) }); allocs != 0 { | |||
| 35 | + | t.Fatalf("dominant allocates %v times", allocs) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 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 | + | ||||
| 48 | + | func TestDominantLargeIconOffColumns(t *testing.T) { | |||
| 49 | + | // 512x512, transparent but for a red logo in columns 10..49: a stride of | |||
| 50 | + | // 64 pixels would sample only columns 0, 64, 128... and miss it. | |||
| 51 | + | const side = 512 | |||
| 52 | + | icon := make([]byte, 0, side*side*4) | |||
| 53 | + | for range side { | |||
| 54 | + | for x := range side { | |||
| 55 | + | if x >= 10 && x < 50 { | |||
| 56 | + | icon = append(icon, 255, 250, 20, 20) | |||
| 57 | + | } else { | |||
| 58 | + | icon = append(icon, 0, 0, 0, 0) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | var h histogram | |||
| 63 | + | if got, ok := h.dominant(icon); !ok || got != (RGB{250, 20, 20}) { | |||
| 64 | + | t.Fatalf("dominant = %v, %v", got, ok) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestReadable(t *testing.T) { | |||
| 69 | + | bg, fg := RGB{0x1e, 0x1e, 0x2e}, RGB{0xcd, 0xd6, 0xf4} | |||
| 70 | + | dark := RGB{0x17, 0x1a, 0x21} // Steam's near-black blue | |||
| 71 | + | got := readable(dark, bg, fg) | |||
| 72 | + | if contrast(got, bg) < 3 { | |||
| 73 | + | t.Fatalf("readable(%v) = %v, contrast %.2f", dark, got, contrast(got, bg)) | |||
| 74 | + | } | |||
| 75 | + | bright := RGB{0xf9, 0xe2, 0xaf} | |||
| 76 | + | if readable(bright, bg, fg) != bright { | |||
| 77 | + | t.Fatal("a readable color must not change") | |||
| 78 | + | } | |||
| 79 | + | } | |||
A internal/tray/resolve.go +415 -0
| @@ -0,0 +1,415 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | "unicode" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Resolver picks the icon of a tray item from what the system already knows, | |
| 13 | + | // so a new application needs no table entry: | |
| 14 | + | // | |
| 15 | + | // 1. the user's [tray.icons] override; | |
| 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"; | |
| 18 | + | // 4. a glyph for the category of its .desktop file, such as Game; | |
| 19 | + | // 5. a letter in a circle. | |
| 20 | + | type Resolver struct { | |
| 21 | + | lookup func(name string) (rune, bool) | |
| 22 | + | icons map[string]string // lowercased keys | |
| 23 | + | dataDirs []string | |
| 24 | + | apps map[string]*desktopApp // nil until the first lookup | |
| 25 | + | mtimes []time.Time // of each applications dir when apps was read | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // desktopApp is what the resolver uses from a .desktop file. | |
| 29 | + | type desktopApp struct { | |
| 30 | + | name string | |
| 31 | + | words []string // file name, Icon and StartupWMClass, lowercased | |
| 32 | + | categories []string | |
| 33 | + | hidden bool // Hidden=true: the application counts as deleted | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // NewResolver returns a resolver that finds glyphs with lookup (a font's | |
| 37 | + | // glyph names) and .desktop files under dataDirs/applications. Nil dataDirs | |
| 38 | + | // uses the XDG data directories. | |
| 39 | + | func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver { | |
| 40 | + | r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs} | |
| 41 | + | for k, v := range icons { | |
| 42 | + | r.icons[strings.ToLower(k)] = v | |
| 43 | + | } | |
| 44 | + | if r.dataDirs == nil { | |
| 45 | + | r.dataDirs = xdgDataDirs() | |
| 46 | + | } | |
| 47 | + | return r | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // prefixes are the Nerd Font sets, brand-rich ones first. | |
| 51 | + | var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"} | |
| 52 | + | ||
| 53 | + | // stopwords are parts of item ids and names that say nothing about the | |
| 54 | + | // application. | |
| 55 | + | var stopwords = map[string]bool{ | |
| 56 | + | "org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true, | |
| 57 | + | "desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true, | |
| 58 | + | "notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true, | |
| 59 | + | "gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true, | |
| 60 | + | "ayatana": true, "notification": true, "electron": true, "bin": true, | |
| 61 | + | } | |
| 62 | + | ||
| 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. | |
| 66 | + | var statusIcons = [...][2]string{ | |
| 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"}, | |
| 70 | + | {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"}, | |
| 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 "" | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // categories maps .desktop categories to glyphs, most specific first: an | |
| 90 | + | // application lists broad categories too, such as "Network;WebBrowser". | |
| 91 | + | var categories = [...][2]string{ | |
| 92 | + | {"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"}, | |
| 93 | + | {"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"}, | |
| 94 | + | {"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"}, | |
| 95 | + | {"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"}, | |
| 96 | + | {"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"}, | |
| 97 | + | {"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"}, | |
| 98 | + | {"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"}, | |
| 99 | + | {"System", "md-cog"}, {"Utility", "md-tools"}, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Resolve returns the icon text of an item: one glyph, or the user's | |
| 103 | + | // override. id, iconName and title are the item's properties; any may be | |
| 104 | + | // empty. A title can say anything ("Sync complete"), so it only counts as a | |
| 105 | + | // whole name, after the stronger signals. | |
| 106 | + | func (r *Resolver) Resolve(id, iconName, title string) string { | |
| 107 | + | if strings.HasPrefix(iconName, "/") { | |
| 108 | + | iconName = "" // a file path names no application | |
| 109 | + | } | |
| 110 | + | keys := [...]string{id, iconName, title} | |
| 111 | + | for _, k := range keys { | |
| 112 | + | if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" { | |
| 113 | + | return r.glyphOr(v, v) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | app := r.app(id, iconName, title) | |
| 117 | + | if app != nil { | |
| 118 | + | if v, ok := r.icons[strings.ToLower(app.name)]; ok { | |
| 119 | + | return r.glyphOr(v, v) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | // Status icon names first: "audio-volume-muted" is a state, not a brand. | |
| 123 | + | if s := statusIcon(iconName); s != "" { | |
| 124 | + | if g, ok := r.glyph(s); ok { | |
| 125 | + | return g | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | names := []string{id, iconName} | |
| 129 | + | if app != nil { | |
| 130 | + | names = append(names, app.words...) | |
| 131 | + | names = append(names, app.name) | |
| 132 | + | } | |
| 133 | + | for _, n := range names { | |
| 134 | + | if g, ok := r.brand(n); ok { | |
| 135 | + | return g | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | if g, ok := r.prefixed(underscored(title)); ok { | |
| 139 | + | return g | |
| 140 | + | } | |
| 141 | + | if app != nil { | |
| 142 | + | for _, c := range categories { | |
| 143 | + | for _, ac := range app.categories { | |
| 144 | + | if ac == c[0] { | |
| 145 | + | if g, ok := r.glyph(c[1]); ok { | |
| 146 | + | return g | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | for _, n := range [...]string{nameOf(app), title, id} { | |
| 153 | + | if l := firstLetter(n); l != 0 { | |
| 154 | + | return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l))) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | return "•" | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func nameOf(app *desktopApp) string { | |
| 161 | + | if app == nil { | |
| 162 | + | return "" | |
| 163 | + | } | |
| 164 | + | return app.name | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // glyph returns the glyph called name as a string. | |
| 168 | + | func (r *Resolver) glyph(name string) (string, bool) { | |
| 169 | + | if rn, ok := r.lookup(name); ok { | |
| 170 | + | return string(rn), true | |
| 171 | + | } | |
| 172 | + | return "", false | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // glyphOr returns the glyph called name, or fallback when the font has none. | |
| 176 | + | func (r *Resolver) glyphOr(name, fallback string) string { | |
| 177 | + | if g, ok := r.glyph(name); ok { | |
| 178 | + | return g | |
| 179 | + | } | |
| 180 | + | return fallback | |
| 181 | + | } | |
| 182 | + | ||
| 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 { | |
| 187 | + | if c == '-' || c == '.' || c == ' ' { | |
| 188 | + | return '_' | |
| 189 | + | } | |
| 190 | + | return unicode.ToLower(c) | |
| 191 | + | }, s) | |
| 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] { | |
| 197 | + | if g, ok := r.prefixed(whole); ok { | |
| 198 | + | return g, true | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | for _, w := range words(s) { | |
| 202 | + | if g, ok := r.prefixed(w); ok { | |
| 203 | + | return g, true | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | return "", false | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (r *Resolver) prefixed(w string) (string, bool) { | |
| 210 | + | if len(w) < 3 { | |
| 211 | + | return "", false | |
| 212 | + | } | |
| 213 | + | for _, p := range prefixes { | |
| 214 | + | if g, ok := r.glyph(p + "-" + w); ok { | |
| 215 | + | return g, true | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | return "", false | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // words splits s into lowercase alphanumeric words of 3 or more letters, | |
| 222 | + | // without stopwords or numbers. | |
| 223 | + | func words(s string) []string { | |
| 224 | + | var out []string | |
| 225 | + | for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool { | |
| 226 | + | return !unicode.IsLetter(c) && !unicode.IsDigit(c) | |
| 227 | + | }) { | |
| 228 | + | if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) { | |
| 229 | + | out = append(out, f) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | return out | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func firstLetter(s string) rune { | |
| 236 | + | for _, c := range strings.ToLower(s) { | |
| 237 | + | if c >= 'a' && c <= 'z' { | |
| 238 | + | return c | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | return 0 | |
| 242 | + | } | |
| 243 | + | ||
| 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 { | |
| 248 | + | if r.apps == nil { | |
| 249 | + | r.reload() | |
| 250 | + | } | |
| 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.mtimes = r.dirTimes(r.mtimes[:0]) | |
| 263 | + | r.apps = loadApps(r.dataDirs) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // dirTimes appends the mtime of each applications directory, zero when it | |
| 267 | + | // is missing. | |
| 268 | + | func (r *Resolver) dirTimes(ts []time.Time) []time.Time { | |
| 269 | + | for _, d := range r.dataDirs { | |
| 270 | + | var t time.Time | |
| 271 | + | if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil { | |
| 272 | + | t = st.ModTime() | |
| 273 | + | } | |
| 274 | + | ts = append(ts, t) | |
| 275 | + | } | |
| 276 | + | return ts | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // findApp looks up the whole id, icon name and title, then the words of the | |
| 280 | + | // id and icon name. Titles are free text: their words are not used. | |
| 281 | + | func (r *Resolver) findApp(id, iconName, title string) *desktopApp { | |
| 282 | + | for _, k := range [...]string{id, iconName, title} { | |
| 283 | + | if a := r.apps[strings.ToLower(k)]; a != nil && k != "" { | |
| 284 | + | return a | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | for _, k := range [...]string{id, iconName} { | |
| 288 | + | for _, w := range words(k) { | |
| 289 | + | if a := r.apps[w]; a != nil { | |
| 290 | + | return a | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | return nil | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // changed reports whether an applications directory changed since the | |
| 298 | + | // index was read. Comparing mtimes with themselves, not with the clock, | |
| 299 | + | // is safe from clock skew. | |
| 300 | + | func (r *Resolver) changed() bool { | |
| 301 | + | var buf [8]time.Time | |
| 302 | + | for i, t := range r.dirTimes(buf[:0]) { | |
| 303 | + | if !t.Equal(r.mtimes[i]) { | |
| 304 | + | return true | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func xdgDataDirs() []string { | |
| 311 | + | home := os.Getenv("XDG_DATA_HOME") | |
| 312 | + | if home == "" { | |
| 313 | + | if h, err := os.UserHomeDir(); err == nil { | |
| 314 | + | home = filepath.Join(h, ".local", "share") | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | dirs := os.Getenv("XDG_DATA_DIRS") | |
| 318 | + | if dirs == "" { | |
| 319 | + | dirs = "/usr/local/share:/usr/share" | |
| 320 | + | } | |
| 321 | + | return append([]string{home}, filepath.SplitList(dirs)...) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // loadApps indexes the .desktop files of dataDirs by lowercased file name, | |
| 325 | + | // Icon and StartupWMClass, and by the last part of reverse-DNS ones | |
| 326 | + | // ("org.gnome.Settings" gives "settings" too). Whole values win over last | |
| 327 | + | // parts, and earlier directories over later ones, as in the XDG spec. A | |
| 328 | + | // hidden application keeps its keys, with no application behind them. | |
| 329 | + | func loadApps(dataDirs []string) map[string]*desktopApp { | |
| 330 | + | apps := map[string]*desktopApp{} | |
| 331 | + | var all []*desktopApp | |
| 332 | + | for _, d := range dataDirs { | |
| 333 | + | if d == "" { | |
| 334 | + | continue | |
| 335 | + | } | |
| 336 | + | files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop")) | |
| 337 | + | for _, f := range files { | |
| 338 | + | if a := readDesktop(f); a != nil { | |
| 339 | + | all = append(all, a) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | add := func(k string, a *desktopApp) { | |
| 344 | + | if _, taken := apps[k]; !taken && k != "" { | |
| 345 | + | if a.hidden { | |
| 346 | + | a = nil | |
| 347 | + | } | |
| 348 | + | apps[k] = a | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | for _, a := range all { | |
| 352 | + | for _, w := range a.words { | |
| 353 | + | add(w, a) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | for _, a := range all { | |
| 357 | + | for _, v := range a.words { | |
| 358 | + | if i := strings.LastIndexByte(v, '.'); i >= 0 { | |
| 359 | + | add(v[i+1:], a) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | return apps | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | // readDesktop reads the keys the resolver needs from the [Desktop Entry] | |
| 367 | + | // group of a .desktop file. | |
| 368 | + | func readDesktop(path string) *desktopApp { | |
| 369 | + | f, err := os.Open(path) | |
| 370 | + | if err != nil { | |
| 371 | + | return nil | |
| 372 | + | } | |
| 373 | + | defer func() { _ = f.Close() }() | |
| 374 | + | a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}} | |
| 375 | + | in := false | |
| 376 | + | sc := bufio.NewScanner(f) | |
| 377 | + | for sc.Scan() { | |
| 378 | + | line := strings.TrimSpace(sc.Text()) | |
| 379 | + | if strings.HasPrefix(line, "[") { | |
| 380 | + | if in { | |
| 381 | + | break // only the first group is the entry | |
| 382 | + | } | |
| 383 | + | in = line == "[Desktop Entry]" | |
| 384 | + | continue | |
| 385 | + | } | |
| 386 | + | k, v, ok := strings.Cut(line, "=") | |
| 387 | + | if !in || !ok { | |
| 388 | + | continue | |
| 389 | + | } | |
| 390 | + | switch strings.TrimSpace(k) { | |
| 391 | + | case "Name": | |
| 392 | + | a.name = strings.TrimSpace(v) | |
| 393 | + | case "Icon": | |
| 394 | + | if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") { | |
| 395 | + | a.words = append(a.words, strings.ToLower(v)) | |
| 396 | + | } | |
| 397 | + | case "StartupWMClass": | |
| 398 | + | if v = strings.TrimSpace(v); v != "" { | |
| 399 | + | a.words = append(a.words, strings.ToLower(v)) | |
| 400 | + | } | |
| 401 | + | case "Categories": | |
| 402 | + | a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' }) | |
| 403 | + | case "Hidden": | |
| 404 | + | a.hidden = strings.TrimSpace(v) == "true" | |
| 405 | + | case "Type": | |
| 406 | + | if strings.TrimSpace(v) != "Application" { | |
| 407 | + | return nil | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | if !in { | |
| 412 | + | return nil | |
| 413 | + | } | |
| 414 | + | return a | |
| 415 | + | } | |
| @@ -0,0 +1,415 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // Resolver picks the icon of a tray item from what the system already knows, | |||
| 13 | + | // so a new application needs no table entry: | |||
| 14 | + | // | |||
| 15 | + | // 1. the user's [tray.icons] override; | |||
| 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"; | |||
| 18 | + | // 4. a glyph for the category of its .desktop file, such as Game; | |||
| 19 | + | // 5. a letter in a circle. | |||
| 20 | + | type Resolver struct { | |||
| 21 | + | lookup func(name string) (rune, bool) | |||
| 22 | + | icons map[string]string // lowercased keys | |||
| 23 | + | dataDirs []string | |||
| 24 | + | apps map[string]*desktopApp // nil until the first lookup | |||
| 25 | + | mtimes []time.Time // of each applications dir when apps was read | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // desktopApp is what the resolver uses from a .desktop file. | |||
| 29 | + | type desktopApp struct { | |||
| 30 | + | name string | |||
| 31 | + | words []string // file name, Icon and StartupWMClass, lowercased | |||
| 32 | + | categories []string | |||
| 33 | + | hidden bool // Hidden=true: the application counts as deleted | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // NewResolver returns a resolver that finds glyphs with lookup (a font's | |||
| 37 | + | // glyph names) and .desktop files under dataDirs/applications. Nil dataDirs | |||
| 38 | + | // uses the XDG data directories. | |||
| 39 | + | func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver { | |||
| 40 | + | r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs} | |||
| 41 | + | for k, v := range icons { | |||
| 42 | + | r.icons[strings.ToLower(k)] = v | |||
| 43 | + | } | |||
| 44 | + | if r.dataDirs == nil { | |||
| 45 | + | r.dataDirs = xdgDataDirs() | |||
| 46 | + | } | |||
| 47 | + | return r | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // prefixes are the Nerd Font sets, brand-rich ones first. | |||
| 51 | + | var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"} | |||
| 52 | + | ||||
| 53 | + | // stopwords are parts of item ids and names that say nothing about the | |||
| 54 | + | // application. | |||
| 55 | + | var stopwords = map[string]bool{ | |||
| 56 | + | "org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true, | |||
| 57 | + | "desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true, | |||
| 58 | + | "notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true, | |||
| 59 | + | "gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true, | |||
| 60 | + | "ayatana": true, "notification": true, "electron": true, "bin": true, | |||
| 61 | + | } | |||
| 62 | + | ||||
| 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. | |||
| 66 | + | var statusIcons = [...][2]string{ | |||
| 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"}, | |||
| 70 | + | {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"}, | |||
| 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 "" | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // categories maps .desktop categories to glyphs, most specific first: an | |||
| 90 | + | // application lists broad categories too, such as "Network;WebBrowser". | |||
| 91 | + | var categories = [...][2]string{ | |||
| 92 | + | {"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"}, | |||
| 93 | + | {"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"}, | |||
| 94 | + | {"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"}, | |||
| 95 | + | {"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"}, | |||
| 96 | + | {"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"}, | |||
| 97 | + | {"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"}, | |||
| 98 | + | {"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"}, | |||
| 99 | + | {"System", "md-cog"}, {"Utility", "md-tools"}, | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Resolve returns the icon text of an item: one glyph, or the user's | |||
| 103 | + | // override. id, iconName and title are the item's properties; any may be | |||
| 104 | + | // empty. A title can say anything ("Sync complete"), so it only counts as a | |||
| 105 | + | // whole name, after the stronger signals. | |||
| 106 | + | func (r *Resolver) Resolve(id, iconName, title string) string { | |||
| 107 | + | if strings.HasPrefix(iconName, "/") { | |||
| 108 | + | iconName = "" // a file path names no application | |||
| 109 | + | } | |||
| 110 | + | keys := [...]string{id, iconName, title} | |||
| 111 | + | for _, k := range keys { | |||
| 112 | + | if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" { | |||
| 113 | + | return r.glyphOr(v, v) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | app := r.app(id, iconName, title) | |||
| 117 | + | if app != nil { | |||
| 118 | + | if v, ok := r.icons[strings.ToLower(app.name)]; ok { | |||
| 119 | + | return r.glyphOr(v, v) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | // Status icon names first: "audio-volume-muted" is a state, not a brand. | |||
| 123 | + | if s := statusIcon(iconName); s != "" { | |||
| 124 | + | if g, ok := r.glyph(s); ok { | |||
| 125 | + | return g | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | names := []string{id, iconName} | |||
| 129 | + | if app != nil { | |||
| 130 | + | names = append(names, app.words...) | |||
| 131 | + | names = append(names, app.name) | |||
| 132 | + | } | |||
| 133 | + | for _, n := range names { | |||
| 134 | + | if g, ok := r.brand(n); ok { | |||
| 135 | + | return g | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | if g, ok := r.prefixed(underscored(title)); ok { | |||
| 139 | + | return g | |||
| 140 | + | } | |||
| 141 | + | if app != nil { | |||
| 142 | + | for _, c := range categories { | |||
| 143 | + | for _, ac := range app.categories { | |||
| 144 | + | if ac == c[0] { | |||
| 145 | + | if g, ok := r.glyph(c[1]); ok { | |||
| 146 | + | return g | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | for _, n := range [...]string{nameOf(app), title, id} { | |||
| 153 | + | if l := firstLetter(n); l != 0 { | |||
| 154 | + | return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l))) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | return "•" | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func nameOf(app *desktopApp) string { | |||
| 161 | + | if app == nil { | |||
| 162 | + | return "" | |||
| 163 | + | } | |||
| 164 | + | return app.name | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // glyph returns the glyph called name as a string. | |||
| 168 | + | func (r *Resolver) glyph(name string) (string, bool) { | |||
| 169 | + | if rn, ok := r.lookup(name); ok { | |||
| 170 | + | return string(rn), true | |||
| 171 | + | } | |||
| 172 | + | return "", false | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // glyphOr returns the glyph called name, or fallback when the font has none. | |||
| 176 | + | func (r *Resolver) glyphOr(name, fallback string) string { | |||
| 177 | + | if g, ok := r.glyph(name); ok { | |||
| 178 | + | return g | |||
| 179 | + | } | |||
| 180 | + | return fallback | |||
| 181 | + | } | |||
| 182 | + | ||||
| 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 { | |||
| 187 | + | if c == '-' || c == '.' || c == ' ' { | |||
| 188 | + | return '_' | |||
| 189 | + | } | |||
| 190 | + | return unicode.ToLower(c) | |||
| 191 | + | }, s) | |||
| 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] { | |||
| 197 | + | if g, ok := r.prefixed(whole); ok { | |||
| 198 | + | return g, true | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | for _, w := range words(s) { | |||
| 202 | + | if g, ok := r.prefixed(w); ok { | |||
| 203 | + | return g, true | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | return "", false | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (r *Resolver) prefixed(w string) (string, bool) { | |||
| 210 | + | if len(w) < 3 { | |||
| 211 | + | return "", false | |||
| 212 | + | } | |||
| 213 | + | for _, p := range prefixes { | |||
| 214 | + | if g, ok := r.glyph(p + "-" + w); ok { | |||
| 215 | + | return g, true | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | return "", false | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // words splits s into lowercase alphanumeric words of 3 or more letters, | |||
| 222 | + | // without stopwords or numbers. | |||
| 223 | + | func words(s string) []string { | |||
| 224 | + | var out []string | |||
| 225 | + | for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool { | |||
| 226 | + | return !unicode.IsLetter(c) && !unicode.IsDigit(c) | |||
| 227 | + | }) { | |||
| 228 | + | if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) { | |||
| 229 | + | out = append(out, f) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | return out | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func firstLetter(s string) rune { | |||
| 236 | + | for _, c := range strings.ToLower(s) { | |||
| 237 | + | if c >= 'a' && c <= 'z' { | |||
| 238 | + | return c | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | return 0 | |||
| 242 | + | } | |||
| 243 | + | ||||
| 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 { | |||
| 248 | + | if r.apps == nil { | |||
| 249 | + | r.reload() | |||
| 250 | + | } | |||
| 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.mtimes = r.dirTimes(r.mtimes[:0]) | |||
| 263 | + | r.apps = loadApps(r.dataDirs) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | // dirTimes appends the mtime of each applications directory, zero when it | |||
| 267 | + | // is missing. | |||
| 268 | + | func (r *Resolver) dirTimes(ts []time.Time) []time.Time { | |||
| 269 | + | for _, d := range r.dataDirs { | |||
| 270 | + | var t time.Time | |||
| 271 | + | if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil { | |||
| 272 | + | t = st.ModTime() | |||
| 273 | + | } | |||
| 274 | + | ts = append(ts, t) | |||
| 275 | + | } | |||
| 276 | + | return ts | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // findApp looks up the whole id, icon name and title, then the words of the | |||
| 280 | + | // id and icon name. Titles are free text: their words are not used. | |||
| 281 | + | func (r *Resolver) findApp(id, iconName, title string) *desktopApp { | |||
| 282 | + | for _, k := range [...]string{id, iconName, title} { | |||
| 283 | + | if a := r.apps[strings.ToLower(k)]; a != nil && k != "" { | |||
| 284 | + | return a | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | for _, k := range [...]string{id, iconName} { | |||
| 288 | + | for _, w := range words(k) { | |||
| 289 | + | if a := r.apps[w]; a != nil { | |||
| 290 | + | return a | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | return nil | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // changed reports whether an applications directory changed since the | |||
| 298 | + | // index was read. Comparing mtimes with themselves, not with the clock, | |||
| 299 | + | // is safe from clock skew. | |||
| 300 | + | func (r *Resolver) changed() bool { | |||
| 301 | + | var buf [8]time.Time | |||
| 302 | + | for i, t := range r.dirTimes(buf[:0]) { | |||
| 303 | + | if !t.Equal(r.mtimes[i]) { | |||
| 304 | + | return true | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | return false | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func xdgDataDirs() []string { | |||
| 311 | + | home := os.Getenv("XDG_DATA_HOME") | |||
| 312 | + | if home == "" { | |||
| 313 | + | if h, err := os.UserHomeDir(); err == nil { | |||
| 314 | + | home = filepath.Join(h, ".local", "share") | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | dirs := os.Getenv("XDG_DATA_DIRS") | |||
| 318 | + | if dirs == "" { | |||
| 319 | + | dirs = "/usr/local/share:/usr/share" | |||
| 320 | + | } | |||
| 321 | + | return append([]string{home}, filepath.SplitList(dirs)...) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // loadApps indexes the .desktop files of dataDirs by lowercased file name, | |||
| 325 | + | // Icon and StartupWMClass, and by the last part of reverse-DNS ones | |||
| 326 | + | // ("org.gnome.Settings" gives "settings" too). Whole values win over last | |||
| 327 | + | // parts, and earlier directories over later ones, as in the XDG spec. A | |||
| 328 | + | // hidden application keeps its keys, with no application behind them. | |||
| 329 | + | func loadApps(dataDirs []string) map[string]*desktopApp { | |||
| 330 | + | apps := map[string]*desktopApp{} | |||
| 331 | + | var all []*desktopApp | |||
| 332 | + | for _, d := range dataDirs { | |||
| 333 | + | if d == "" { | |||
| 334 | + | continue | |||
| 335 | + | } | |||
| 336 | + | files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop")) | |||
| 337 | + | for _, f := range files { | |||
| 338 | + | if a := readDesktop(f); a != nil { | |||
| 339 | + | all = append(all, a) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | add := func(k string, a *desktopApp) { | |||
| 344 | + | if _, taken := apps[k]; !taken && k != "" { | |||
| 345 | + | if a.hidden { | |||
| 346 | + | a = nil | |||
| 347 | + | } | |||
| 348 | + | apps[k] = a | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | for _, a := range all { | |||
| 352 | + | for _, w := range a.words { | |||
| 353 | + | add(w, a) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | for _, a := range all { | |||
| 357 | + | for _, v := range a.words { | |||
| 358 | + | if i := strings.LastIndexByte(v, '.'); i >= 0 { | |||
| 359 | + | add(v[i+1:], a) | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | return apps | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | // readDesktop reads the keys the resolver needs from the [Desktop Entry] | |||
| 367 | + | // group of a .desktop file. | |||
| 368 | + | func readDesktop(path string) *desktopApp { | |||
| 369 | + | f, err := os.Open(path) | |||
| 370 | + | if err != nil { | |||
| 371 | + | return nil | |||
| 372 | + | } | |||
| 373 | + | defer func() { _ = f.Close() }() | |||
| 374 | + | a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}} | |||
| 375 | + | in := false | |||
| 376 | + | sc := bufio.NewScanner(f) | |||
| 377 | + | for sc.Scan() { | |||
| 378 | + | line := strings.TrimSpace(sc.Text()) | |||
| 379 | + | if strings.HasPrefix(line, "[") { | |||
| 380 | + | if in { | |||
| 381 | + | break // only the first group is the entry | |||
| 382 | + | } | |||
| 383 | + | in = line == "[Desktop Entry]" | |||
| 384 | + | continue | |||
| 385 | + | } | |||
| 386 | + | k, v, ok := strings.Cut(line, "=") | |||
| 387 | + | if !in || !ok { | |||
| 388 | + | continue | |||
| 389 | + | } | |||
| 390 | + | switch strings.TrimSpace(k) { | |||
| 391 | + | case "Name": | |||
| 392 | + | a.name = strings.TrimSpace(v) | |||
| 393 | + | case "Icon": | |||
| 394 | + | if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") { | |||
| 395 | + | a.words = append(a.words, strings.ToLower(v)) | |||
| 396 | + | } | |||
| 397 | + | case "StartupWMClass": | |||
| 398 | + | if v = strings.TrimSpace(v); v != "" { | |||
| 399 | + | a.words = append(a.words, strings.ToLower(v)) | |||
| 400 | + | } | |||
| 401 | + | case "Categories": | |||
| 402 | + | a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' }) | |||
| 403 | + | case "Hidden": | |||
| 404 | + | a.hidden = strings.TrimSpace(v) == "true" | |||
| 405 | + | case "Type": | |||
| 406 | + | if strings.TrimSpace(v) != "Application" { | |||
| 407 | + | return nil | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | if !in { | |||
| 412 | + | return nil | |||
| 413 | + | } | |||
| 414 | + | return a | |||
| 415 | + | } | |||
A internal/tray/resolve_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // fakeFont knows a few glyph names; each maps to a letter so results read | |
| 11 | + | // easily. | |
| 12 | + | var fakeFont = map[string]rune{ | |
| 13 | + | "fa-steam": 'S', | |
| 14 | + | "custom-obsidian": 'O', | |
| 15 | + | "md-volume_off": 'M', | |
| 16 | + | "md-gamepad_variant": 'G', | |
| 17 | + | "md-web": 'W', | |
| 18 | + | "md-lan": 'N', | |
| 19 | + | "md-alpha_z_circle": 'Z', | |
| 20 | + | "md-google_chrome": 'C', | |
| 21 | + | "md-wifi": 'F', | |
| 22 | + | "md-sync": 'Y', | |
| 23 | + | "md-chat": 'H', | |
| 24 | + | "md-cog": 'K', | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func lookup(name string) (rune, bool) { | |
| 28 | + | r, ok := fakeFont[name] | |
| 29 | + | return r, ok | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func writeDesktop(t *testing.T, dir, name, body string) { | |
| 33 | + | t.Helper() | |
| 34 | + | apps := filepath.Join(dir, "applications") | |
| 35 | + | if err := os.MkdirAll(apps, 0o755); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestResolve(t *testing.T) { | |
| 44 | + | dir := t.TempDir() | |
| 45 | + | writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n") | |
| 46 | + | writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n") | |
| 47 | + | writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n") | |
| 48 | + | writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n") | |
| 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}) | |
| 56 | + | ||
| 57 | + | for _, c := range []struct { | |
| 58 | + | name, id, icon, title, want string | |
| 59 | + | }{ | |
| 60 | + | {"brand from the id", "steam", "", "", "S"}, | |
| 61 | + | {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"}, | |
| 62 | + | {"brand from the whole name", "google-chrome", "", "", "C"}, | |
| 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"}, | |
| 73 | + | {"category from the desktop file", "racer", "racer-game", "", "G"}, | |
| 74 | + | {"most specific category wins", "chromium", "", "", "W"}, | |
| 75 | + | {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"}, | |
| 76 | + | {"literal override, any case", "sunshine", "", "", "☀"}, | |
| 77 | + | {"glyph-name override", "", "", "mail", "N"}, | |
| 78 | + | {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"}, | |
| 79 | + | {"letter without the font glyph", "Quux", "", "", "Q"}, | |
| 80 | + | {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"}, | |
| 81 | + | {"nothing to go on", "", "", "", "•"}, | |
| 82 | + | } { | |
| 83 | + | if got := r.Resolve(c.id, c.icon, c.title); got != c.want { | |
| 84 | + | t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 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 | + | // Make sure the directory's mtime differs from the one at the load, | |
| 97 | + | // even on a coarse clock. | |
| 98 | + | later := time.Now().Add(time.Hour) | |
| 99 | + | if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | if got := r.Resolve("racer", "", ""); got != "G" { | |
| 103 | + | t.Fatalf("after install: %q, want G", got) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) { | |
| 108 | + | // A directory dated in the future does not make every miss rescan. | |
| 109 | + | dir := t.TempDir() | |
| 110 | + | writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n") | |
| 111 | + | future := time.Now().Add(24 * time.Hour) | |
| 112 | + | if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | r := NewResolver(lookup, nil, []string{dir}) | |
| 116 | + | r.Resolve("unknown", "", "") | |
| 117 | + | if r.changed() { | |
| 118 | + | t.Fatal("an unchanged directory counts as changed") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestEmptyOverrideHides(t *testing.T) { | |
| 123 | + | r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()}) | |
| 124 | + | if got := r.Resolve("steam", "", ""); got != "" { | |
| 125 | + | t.Fatalf("Resolve = %q, want empty", got) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestWords(t *testing.T) { | |
| 130 | + | got := words("org.telegram.desktop_2-Tray 42") | |
| 131 | + | if len(got) != 1 || got[0] != "telegram" { | |
| 132 | + | t.Fatalf("words = %q", got) | |
| 133 | + | } | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // fakeFont knows a few glyph names; each maps to a letter so results read | |||
| 11 | + | // easily. | |||
| 12 | + | var fakeFont = map[string]rune{ | |||
| 13 | + | "fa-steam": 'S', | |||
| 14 | + | "custom-obsidian": 'O', | |||
| 15 | + | "md-volume_off": 'M', | |||
| 16 | + | "md-gamepad_variant": 'G', | |||
| 17 | + | "md-web": 'W', | |||
| 18 | + | "md-lan": 'N', | |||
| 19 | + | "md-alpha_z_circle": 'Z', | |||
| 20 | + | "md-google_chrome": 'C', | |||
| 21 | + | "md-wifi": 'F', | |||
| 22 | + | "md-sync": 'Y', | |||
| 23 | + | "md-chat": 'H', | |||
| 24 | + | "md-cog": 'K', | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func lookup(name string) (rune, bool) { | |||
| 28 | + | r, ok := fakeFont[name] | |||
| 29 | + | return r, ok | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func writeDesktop(t *testing.T, dir, name, body string) { | |||
| 33 | + | t.Helper() | |||
| 34 | + | apps := filepath.Join(dir, "applications") | |||
| 35 | + | if err := os.MkdirAll(apps, 0o755); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if err := os.WriteFile(filepath.Join(apps, name+".desktop"), []byte(body), 0o644); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestResolve(t *testing.T) { | |||
| 44 | + | dir := t.TempDir() | |||
| 45 | + | writeDesktop(t, dir, "com.example.Racer", "[Desktop Entry]\nName=Racer\nIcon=racer-game\nCategories=Game;\n") | |||
| 46 | + | writeDesktop(t, dir, "chromium", "[Desktop Entry]\nName=Chromium\nIcon=chromium\nCategories=Network;WebBrowser;\n") | |||
| 47 | + | writeDesktop(t, dir, "zed", "[Desktop Entry]\nName=Zed\nStartupWMClass=dev.zed.Zed\n\n[Desktop Action new]\nCategories=Game;\n") | |||
| 48 | + | writeDesktop(t, dir, "dev.lizardbyte.app.Sunrise", "[Desktop Entry]\nName=Sunrise\nCategories=RemoteAccess;Network;\n") | |||
| 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}) | |||
| 56 | + | ||||
| 57 | + | for _, c := range []struct { | |||
| 58 | + | name, id, icon, title, want string | |||
| 59 | + | }{ | |||
| 60 | + | {"brand from the id", "steam", "", "", "S"}, | |||
| 61 | + | {"brand from a word of the id", "obsidian_status_icon_1", "", "", "O"}, | |||
| 62 | + | {"brand from the whole name", "google-chrome", "", "", "C"}, | |||
| 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"}, | |||
| 73 | + | {"category from the desktop file", "racer", "racer-game", "", "G"}, | |||
| 74 | + | {"most specific category wins", "chromium", "", "", "W"}, | |||
| 75 | + | {"desktop file found by a word of its name", "Sunrise", "", "Sunrise", "N"}, | |||
| 76 | + | {"literal override, any case", "sunshine", "", "", "☀"}, | |||
| 77 | + | {"glyph-name override", "", "", "mail", "N"}, | |||
| 78 | + | {"letter of the desktop name; actions are ignored", "dev.zed.Zed", "", "", "Z"}, | |||
| 79 | + | {"letter without the font glyph", "Quux", "", "", "Q"}, | |||
| 80 | + | {"a file path is not a name", "", "/run/user/1/steam.png", "", "•"}, | |||
| 81 | + | {"nothing to go on", "", "", "", "•"}, | |||
| 82 | + | } { | |||
| 83 | + | if got := r.Resolve(c.id, c.icon, c.title); got != c.want { | |||
| 84 | + | t.Errorf("%s: Resolve(%q, %q, %q) = %q, want %q", c.name, c.id, c.icon, c.title, got, c.want) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 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 | + | // Make sure the directory's mtime differs from the one at the load, | |||
| 97 | + | // even on a coarse clock. | |||
| 98 | + | later := time.Now().Add(time.Hour) | |||
| 99 | + | if err := os.Chtimes(filepath.Join(dir, "applications"), later, later); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | if got := r.Resolve("racer", "", ""); got != "G" { | |||
| 103 | + | t.Fatalf("after install: %q, want G", got) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestResolveDoesNotRescanUnchangedDirs(t *testing.T) { | |||
| 108 | + | // A directory dated in the future does not make every miss rescan. | |||
| 109 | + | dir := t.TempDir() | |||
| 110 | + | writeDesktop(t, dir, "racer", "[Desktop Entry]\nName=Racer\nCategories=Game;\n") | |||
| 111 | + | future := time.Now().Add(24 * time.Hour) | |||
| 112 | + | if err := os.Chtimes(filepath.Join(dir, "applications"), future, future); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | r := NewResolver(lookup, nil, []string{dir}) | |||
| 116 | + | r.Resolve("unknown", "", "") | |||
| 117 | + | if r.changed() { | |||
| 118 | + | t.Fatal("an unchanged directory counts as changed") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestEmptyOverrideHides(t *testing.T) { | |||
| 123 | + | r := NewResolver(lookup, map[string]string{"steam": ""}, []string{t.TempDir()}) | |||
| 124 | + | if got := r.Resolve("steam", "", ""); got != "" { | |||
| 125 | + | t.Fatalf("Resolve = %q, want empty", got) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestWords(t *testing.T) { | |||
| 130 | + | got := words("org.telegram.desktop_2-Tray 42") | |||
| 131 | + | if len(got) != 1 || got[0] != "telegram" { | |||
| 132 | + | t.Fatalf("words = %q", got) | |||
| 133 | + | } | |||
| 134 | + | } | |||
A internal/tray/tray.go +732 -0
| @@ -0,0 +1,732 @@ | |||
| 1 | + | // Package tray shows the system tray (StatusNotifierItem) as one line of | |
| 2 | + | // Nerd Font glyphs, for "neferbar tray". | |
| 3 | + | // | |
| 4 | + | // It acts as the StatusNotifierWatcher when no other program does, and as a | |
| 5 | + | // plain host of the existing watcher otherwise, so it can run next to another | |
| 6 | + | // panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage; | |
| 7 | + | // calls to applications never block it, as their replies are matched by | |
| 8 | + | // serial when they arrive. | |
| 9 | + | package tray | |
| 10 | + | ||
| 11 | + | import ( | |
| 12 | + | "bytes" | |
| 13 | + | "context" | |
| 14 | + | "errors" | |
| 15 | + | "fmt" | |
| 16 | + | "io" | |
| 17 | + | "log/slog" | |
| 18 | + | "os" | |
| 19 | + | "slices" | |
| 20 | + | "strconv" | |
| 21 | + | "strings" | |
| 22 | + | ||
| 23 | + | "github.com/bnema/zerobus" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | watcherName = "org.kde.StatusNotifierWatcher" | |
| 28 | + | watcherPath = "/StatusNotifierWatcher" | |
| 29 | + | watcherIface = "org.kde.StatusNotifierWatcher" | |
| 30 | + | itemIface = "org.kde.StatusNotifierItem" | |
| 31 | + | itemPath = "/StatusNotifierItem" | |
| 32 | + | propsIface = "org.freedesktop.DBus.Properties" | |
| 33 | + | busName = "org.freedesktop.DBus" | |
| 34 | + | busPath = "/org/freedesktop/DBus" | |
| 35 | + | ) | |
| 36 | + | ||
| 37 | + | // RGB is a color. | |
| 38 | + | type RGB = [3]uint8 | |
| 39 | + | ||
| 40 | + | // Options configure a Tray. | |
| 41 | + | type Options struct { | |
| 42 | + | Resolver *Resolver | |
| 43 | + | // Foreground colors items without an icon to take a color from; | |
| 44 | + | // Background is what tinted icons must stand out against; Accent marks | |
| 45 | + | // items that need attention. | |
| 46 | + | Foreground, Background, Accent RGB | |
| 47 | + | Out io.Writer // one line per change | |
| 48 | + | Log *slog.Logger | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type status uint8 | |
| 52 | + | ||
| 53 | + | const ( | |
| 54 | + | active status = iota | |
| 55 | + | passive | |
| 56 | + | attention | |
| 57 | + | ) | |
| 58 | + | ||
| 59 | + | type item struct { | |
| 60 | + | service string // as listed by the watcher: "bus/path" or "bus" | |
| 61 | + | dest string // where to send calls: the owner, or the registered bus name | |
| 62 | + | owner string // the unique name; signals come from it | |
| 63 | + | path string | |
| 64 | + | ||
| 65 | + | id, iconName, title string | |
| 66 | + | status status | |
| 67 | + | icon string // resolved glyph; "" hides the item | |
| 68 | + | resolved bool | |
| 69 | + | color RGB | |
| 70 | + | hasColor bool | |
| 71 | + | loaded bool // GetAll answered at least once | |
| 72 | + | pending uint32 // serial of a GetAll in flight | |
| 73 | + | stale bool // a change arrived while GetAll was in flight | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | type pendKind uint8 | |
| 77 | + | ||
| 78 | + | const ( | |
| 79 | + | pendGetAll pendKind = iota + 1 | |
| 80 | + | pendItems // the watcher's RegisteredStatusNotifierItems | |
| 81 | + | pendName // RequestName of the watcher name | |
| 82 | + | pendIgnore | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | type pend struct { | |
| 86 | + | serial uint32 | |
| 87 | + | kind pendKind | |
| 88 | + | it *item | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Tray is one running tray. | |
| 92 | + | type Tray struct { | |
| 93 | + | c *zerobus.Conn | |
| 94 | + | opt Options | |
| 95 | + | watcher bool // this process owns the watcher name | |
| 96 | + | host string | |
| 97 | + | items []*item | |
| 98 | + | pending []pend | |
| 99 | + | frame []byte | |
| 100 | + | last []byte | |
| 101 | + | hist histogram | |
| 102 | + | err error // first fatal error from dispatch | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Run serves the tray on c until ctx ends or the bus connection fails. It | |
| 106 | + | // closes c. | |
| 107 | + | func Run(ctx context.Context, c *zerobus.Conn, opt Options) error { | |
| 108 | + | t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()), | |
| 109 | + | frame: make([]byte, 0, 256), last: make([]byte, 0, 256)} | |
| 110 | + | if t.opt.Log == nil { | |
| 111 | + | t.opt.Log = slog.New(slog.DiscardHandler) | |
| 112 | + | } | |
| 113 | + | stop := context.AfterFunc(ctx, func() { _ = c.Close() }) | |
| 114 | + | defer stop() | |
| 115 | + | defer func() { _ = c.Close() }() | |
| 116 | + | ||
| 117 | + | // Calls made from here on only go to the bus, which always answers. | |
| 118 | + | // Handle sees what arrives meanwhile. | |
| 119 | + | c.Handle = t.dispatch | |
| 120 | + | for _, rule := range [...]string{ | |
| 121 | + | "type='signal',interface='" + itemIface + "'", | |
| 122 | + | // The bus matches a well-known sender by its current owner. | |
| 123 | + | "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'", | |
| 124 | + | "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'", | |
| 125 | + | } { | |
| 126 | + | if err := c.AddMatch(rule); err != nil { | |
| 127 | + | return t.exit(ctx, fmt.Errorf("tray: add match: %w", err)) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil { | |
| 131 | + | return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err)) | |
| 132 | + | } | |
| 133 | + | t.claim() | |
| 134 | + | if err := t.render(true); err != nil { | |
| 135 | + | return t.exit(ctx, err) | |
| 136 | + | } | |
| 137 | + | for t.err == nil { | |
| 138 | + | m, err := c.ReadMessage() | |
| 139 | + | if err != nil { | |
| 140 | + | return t.exit(ctx, err) | |
| 141 | + | } | |
| 142 | + | t.dispatch(m) | |
| 143 | + | } | |
| 144 | + | // A send fails too when ctx ends during dispatch and closes c. | |
| 145 | + | return t.exit(ctx, t.err) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // exit returns nil when the error comes from ctx ending. | |
| 149 | + | func (t *Tray) exit(ctx context.Context, err error) error { | |
| 150 | + | if ctx.Err() != nil { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | return err | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (t *Tray) send() uint32 { | |
| 157 | + | serial, err := t.c.Send() | |
| 158 | + | if err != nil { | |
| 159 | + | if errors.Is(err, zerobus.ErrInvalid) { | |
| 160 | + | // A name or path an application gave us that D-Bus refuses. | |
| 161 | + | t.opt.Log.Warn("tray: message not sent", "err", err) | |
| 162 | + | return 0 | |
| 163 | + | } | |
| 164 | + | t.fatal(err) | |
| 165 | + | return 0 | |
| 166 | + | } | |
| 167 | + | return serial | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (t *Tray) fatal(err error) { | |
| 171 | + | if t.err == nil { | |
| 172 | + | t.err = err | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (t *Tray) expect(serial uint32, kind pendKind, it *item) { | |
| 177 | + | if serial != 0 { | |
| 178 | + | t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it}) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // claim asks for the watcher name; the reply decides the mode. | |
| 183 | + | func (t *Tray) claim() { | |
| 184 | + | e := t.c.NewCall(busName, busPath, busName, "RequestName", "su") | |
| 185 | + | e.Str(watcherName) | |
| 186 | + | e.Uint32(zerobus.NameFlagDoNotQueue) | |
| 187 | + | t.expect(t.send(), pendName, nil) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (t *Tray) dispatch(m *zerobus.Message) { | |
| 191 | + | switch m.Type { | |
| 192 | + | case zerobus.TypeMethodReturn, zerobus.TypeError: | |
| 193 | + | i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial }) | |
| 194 | + | if i < 0 { | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | p := t.pending[i] | |
| 198 | + | t.pending = slices.Delete(t.pending, i, i+1) | |
| 199 | + | t.reply(p, m) | |
| 200 | + | case zerobus.TypeSignal: | |
| 201 | + | t.signal(m) | |
| 202 | + | case zerobus.TypeMethodCall: | |
| 203 | + | t.call(m) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (t *Tray) reply(p pend, m *zerobus.Message) { | |
| 208 | + | failed := m.Type == zerobus.TypeError | |
| 209 | + | switch p.kind { | |
| 210 | + | case pendName: | |
| 211 | + | code := m.Body().Uint32() | |
| 212 | + | if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) { | |
| 213 | + | t.becomeWatcher() | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | t.becomeHost() | |
| 217 | + | case pendItems: | |
| 218 | + | if failed { | |
| 219 | + | t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName) | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | r := m.Body() | |
| 223 | + | if m.Signature != "v" || r.Variant() != "as" { | |
| 224 | + | t.opt.Log.Warn("tray: the watcher listed its items in an unknown form") | |
| 225 | + | return | |
| 226 | + | } | |
| 227 | + | end := r.Array('s') | |
| 228 | + | for r.More(end) { | |
| 229 | + | t.add(r.Str(), "") | |
| 230 | + | } | |
| 231 | + | case pendGetAll: | |
| 232 | + | it := p.it | |
| 233 | + | if it.pending != m.ReplySerial || !slices.Contains(t.items, it) { | |
| 234 | + | return // removed, or an older request | |
| 235 | + | } | |
| 236 | + | it.pending = 0 | |
| 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) | |
| 239 | + | if !it.loaded { | |
| 240 | + | t.remove(it) | |
| 241 | + | } | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | if it.owner != m.Sender { | |
| 245 | + | it.owner = strings.Clone(m.Sender) | |
| 246 | + | } | |
| 247 | + | t.load(it, m.Body()) | |
| 248 | + | if it.stale { | |
| 249 | + | t.refresh(it) | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (t *Tray) becomeWatcher() { | |
| 255 | + | t.watcher = true | |
| 256 | + | t.opt.Log.Info("tray: acting as the StatusNotifierWatcher") | |
| 257 | + | // Items that waited for a host register now. | |
| 258 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "") | |
| 259 | + | t.send() | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (t *Tray) becomeHost() { | |
| 263 | + | t.watcher = false | |
| 264 | + | t.opt.Log.Info("tray: using the existing StatusNotifierWatcher") | |
| 265 | + | t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host) | |
| 266 | + | t.expect(t.send(), pendIgnore, nil) | |
| 267 | + | e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss") | |
| 268 | + | e.Str(watcherIface) | |
| 269 | + | e.Str("RegisteredStatusNotifierItems") | |
| 270 | + | t.expect(t.send(), pendItems, nil) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (t *Tray) signal(m *zerobus.Message) { | |
| 274 | + | switch m.Interface { | |
| 275 | + | case busName: | |
| 276 | + | if m.Member != "NameOwnerChanged" || m.Sender != busName { | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | r := m.Body() | |
| 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, not a late list from the old one. | |
| 288 | + | t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems }) | |
| 289 | + | t.dropAll() | |
| 290 | + | t.becomeHost() | |
| 291 | + | } | |
| 292 | + | case watcherIface: | |
| 293 | + | if t.watcher || m.Signature != "s" { | |
| 294 | + | return // our own signals come back to us too | |
| 295 | + | } | |
| 296 | + | s := m.Body().Str() | |
| 297 | + | switch m.Member { | |
| 298 | + | case "StatusNotifierItemRegistered": | |
| 299 | + | t.add(s, "") | |
| 300 | + | case "StatusNotifierItemUnregistered": | |
| 301 | + | svc, _, _ := normService(s) | |
| 302 | + | if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 { | |
| 303 | + | t.remove(t.items[i]) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | case itemIface: | |
| 307 | + | switch m.Member { | |
| 308 | + | case "NewToolTip", "NewMenu", "NewOverlayIcon": | |
| 309 | + | return // nothing the tray shows | |
| 310 | + | } | |
| 311 | + | for _, it := range t.items { | |
| 312 | + | if it.owner == m.Sender && it.path == m.Path { | |
| 313 | + | t.refresh(it) | |
| 314 | + | return | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | // vanished drops the items of a bus name that left, and takes over the | |
| 321 | + | // watcher name when its owner left. | |
| 322 | + | func (t *Tray) vanished(name string) { | |
| 323 | + | changed := false | |
| 324 | + | for i := len(t.items) - 1; i >= 0; i-- { | |
| 325 | + | if it := t.items[i]; it.owner == name || it.dest == name { | |
| 326 | + | t.drop(i) | |
| 327 | + | changed = true | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | if changed { | |
| 331 | + | t.renderOrFail() | |
| 332 | + | } | |
| 333 | + | if name == watcherName && !t.watcher { | |
| 334 | + | // Items register again with the new watcher. | |
| 335 | + | t.dropAll() | |
| 336 | + | t.claim() | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func (t *Tray) dropAll() { | |
| 341 | + | if len(t.items) == 0 { | |
| 342 | + | return | |
| 343 | + | } | |
| 344 | + | for i := len(t.items) - 1; i >= 0; i-- { | |
| 345 | + | t.drop(i) | |
| 346 | + | } | |
| 347 | + | t.renderOrFail() | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // normService turns the watcher's form of an item, "bus/path" or "bus", into | |
| 351 | + | // "bus/path", its bus and its path. | |
| 352 | + | func normService(s string) (service, bus, path string) { | |
| 353 | + | bus, p, ok := strings.Cut(s, "/") | |
| 354 | + | if !ok { | |
| 355 | + | return bus + itemPath, bus, itemPath | |
| 356 | + | } | |
| 357 | + | return s, bus, "/" + p | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | // validBusName reports whether s is a unique (":1.42") or well-known | |
| 361 | + | // ("org.example.App") bus name, as D-Bus defines them. | |
| 362 | + | func validBusName(s string) bool { | |
| 363 | + | if s == "" || len(s) > 255 { | |
| 364 | + | return false | |
| 365 | + | } | |
| 366 | + | unique := s[0] == ':' | |
| 367 | + | if unique { | |
| 368 | + | s = s[1:] | |
| 369 | + | } | |
| 370 | + | n := 0 | |
| 371 | + | for more := true; more; n++ { | |
| 372 | + | var e string | |
| 373 | + | e, s, more = strings.Cut(s, ".") | |
| 374 | + | if e == "" || !unique && e[0] >= '0' && e[0] <= '9' { | |
| 375 | + | return false | |
| 376 | + | } | |
| 377 | + | for _, c := range []byte(e) { | |
| 378 | + | if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { | |
| 379 | + | return false | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | return n >= 2 | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // addResult says what add did. | |
| 387 | + | type addResult uint8 | |
| 388 | + | ||
| 389 | + | const ( | |
| 390 | + | addNew addResult = iota | |
| 391 | + | addKnown | |
| 392 | + | addInvalid // D-Bus refuses its bus name or path | |
| 393 | + | ) | |
| 394 | + | ||
| 395 | + | // add starts tracking an item. service is the watcher's form, "bus/path" or | |
| 396 | + | // "bus"; owner is its unique name when known. | |
| 397 | + | func (t *Tray) add(service, owner string) addResult { | |
| 398 | + | service, dest, path := normService(service) | |
| 399 | + | if owner == "" && strings.HasPrefix(dest, ":") { | |
| 400 | + | owner = dest | |
| 401 | + | } | |
| 402 | + | for _, it := range t.items { | |
| 403 | + | if it.service == service || owner != "" && it.owner == owner && it.path == path { | |
| 404 | + | return addKnown | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)} | |
| 408 | + | t.items = append(t.items, it) | |
| 409 | + | t.refresh(it) | |
| 410 | + | if it.pending == 0 { | |
| 411 | + | // Never shown nor announced: forget it quietly. | |
| 412 | + | t.items = t.items[:len(t.items)-1] | |
| 413 | + | return addInvalid | |
| 414 | + | } | |
| 415 | + | return addNew | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func (t *Tray) remove(it *item) { | |
| 419 | + | if i := slices.Index(t.items, it); i >= 0 { | |
| 420 | + | t.drop(i) | |
| 421 | + | t.renderOrFail() | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (t *Tray) drop(i int) { | |
| 426 | + | it := t.items[i] | |
| 427 | + | t.items = slices.Delete(t.items, i, i+1) | |
| 428 | + | if t.watcher { | |
| 429 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service) | |
| 430 | + | t.send() | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // refresh asks an item for its properties, unless a request is in flight. | |
| 435 | + | func (t *Tray) refresh(it *item) { | |
| 436 | + | if it.pending != 0 { | |
| 437 | + | it.stale = true | |
| 438 | + | return | |
| 439 | + | } | |
| 440 | + | it.stale = false | |
| 441 | + | dest := it.dest | |
| 442 | + | if it.owner != "" { | |
| 443 | + | dest = it.owner | |
| 444 | + | } | |
| 445 | + | t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface) | |
| 446 | + | it.pending = t.send() | |
| 447 | + | t.expect(it.pending, pendGetAll, it) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // load reads a GetAll reply into it and redraws when something shown | |
| 451 | + | // changed. Strings are copied only when they change. | |
| 452 | + | func (t *Tray) load(it *item, r *zerobus.Reader) { | |
| 453 | + | was := *it | |
| 454 | + | var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind | |
| 455 | + | end := r.Array('{') | |
| 456 | + | for r.More(end) { | |
| 457 | + | r.Struct() | |
| 458 | + | key := r.Str() | |
| 459 | + | sig := r.Variant() | |
| 460 | + | switch { | |
| 461 | + | case sig == "s" && key == "Id": | |
| 462 | + | setStr(&it.id, r.Str()) | |
| 463 | + | case sig == "s" && key == "Title": | |
| 464 | + | setStr(&it.title, r.Str()) | |
| 465 | + | case sig == "s" && key == "IconName": | |
| 466 | + | setStr(&it.iconName, r.Str()) | |
| 467 | + | case sig == "s" && key == "Status": | |
| 468 | + | it.status = parseStatus(r.Str()) | |
| 469 | + | case sig == "a(iiay)" && key == "IconPixmap": | |
| 470 | + | pixmaps = pickPixmap(r) | |
| 471 | + | case sig == "a(iiay)" && key == "AttentionIconPixmap": | |
| 472 | + | attentionPixmaps = pickPixmap(r) | |
| 473 | + | default: | |
| 474 | + | r.Skip(sig) | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | if r.Err() != nil { | |
| 478 | + | t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err()) | |
| 479 | + | *it = was | |
| 480 | + | return | |
| 481 | + | } | |
| 482 | + | it.loaded = true | |
| 483 | + | if it.status == attention && attentionPixmaps != nil { | |
| 484 | + | pixmaps = attentionPixmaps | |
| 485 | + | } | |
| 486 | + | it.color, it.hasColor = t.hist.dominant(pixmaps) | |
| 487 | + | if it.hasColor { | |
| 488 | + | it.color = readable(it.color, t.opt.Background, t.opt.Foreground) | |
| 489 | + | } | |
| 490 | + | if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title { | |
| 491 | + | it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true | |
| 492 | + | } | |
| 493 | + | t.renderOrFail() | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap | |
| 497 | + | // best suited to find a color: the largest up to 64x64, else the smallest. | |
| 498 | + | // The result points into the message buffer. | |
| 499 | + | func pickPixmap(r *zerobus.Reader) []byte { | |
| 500 | + | var best []byte | |
| 501 | + | bestN := 0 | |
| 502 | + | end := r.Array('(') | |
| 503 | + | for r.More(end) { | |
| 504 | + | r.Struct() | |
| 505 | + | w, h := int(r.Int32()), int(r.Int32()) | |
| 506 | + | data := r.ByteArray() | |
| 507 | + | n := w * h | |
| 508 | + | if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n { | |
| 509 | + | continue | |
| 510 | + | } | |
| 511 | + | if best == nil || better(n, bestN) { | |
| 512 | + | best, bestN = data, n | |
| 513 | + | } | |
| 514 | + | } | |
| 515 | + | return best | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // better reports whether an icon of n pixels suits the color search better | |
| 519 | + | // than one of cur pixels: the largest up to 64x64, else the smallest. | |
| 520 | + | func better(n, cur int) bool { | |
| 521 | + | const enough = 64 * 64 | |
| 522 | + | if cur > enough { | |
| 523 | + | return n < cur | |
| 524 | + | } | |
| 525 | + | return n > cur && n <= enough | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | func setStr(dst *string, s string) { | |
| 529 | + | if *dst != s { | |
| 530 | + | *dst = strings.Clone(s) | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | func parseStatus(s string) status { | |
| 535 | + | switch s { | |
| 536 | + | case "Passive": | |
| 537 | + | return passive | |
| 538 | + | case "NeedsAttention": | |
| 539 | + | return attention | |
| 540 | + | } | |
| 541 | + | return active | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | // call answers method calls when this process is the watcher. | |
| 545 | + | func (t *Tray) call(m *zerobus.Message) { | |
| 546 | + | noReply := m.Flags&zerobus.FlagNoReplyExpected != 0 | |
| 547 | + | if !t.watcher || m.Path != watcherPath { | |
| 548 | + | if !noReply { | |
| 549 | + | t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object") | |
| 550 | + | t.send() | |
| 551 | + | } | |
| 552 | + | return | |
| 553 | + | } | |
| 554 | + | switch { | |
| 555 | + | case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s": | |
| 556 | + | s := m.Body().Str() | |
| 557 | + | if s == "" { | |
| 558 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service") | |
| 559 | + | return | |
| 560 | + | } | |
| 561 | + | if strings.HasPrefix(s, "/") { | |
| 562 | + | s = m.Sender + s // registered by object path | |
| 563 | + | } else if validBusName(s) { | |
| 564 | + | s += itemPath | |
| 565 | + | } else { | |
| 566 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service") | |
| 567 | + | return | |
| 568 | + | } | |
| 569 | + | // add copies s before the next read, and sends only a GetAll. | |
| 570 | + | switch t.add(s, m.Sender) { | |
| 571 | + | case addInvalid: | |
| 572 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service") | |
| 573 | + | return | |
| 574 | + | case addNew: | |
| 575 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service) | |
| 576 | + | t.send() | |
| 577 | + | } | |
| 578 | + | if !noReply { | |
| 579 | + | t.c.NewReply(m, "") | |
| 580 | + | t.send() | |
| 581 | + | } | |
| 582 | + | case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost": | |
| 583 | + | if !noReply { | |
| 584 | + | t.c.NewReply(m, "") | |
| 585 | + | t.send() | |
| 586 | + | } | |
| 587 | + | case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss": | |
| 588 | + | r := m.Body() | |
| 589 | + | iface, prop := r.Str(), r.Str() | |
| 590 | + | if iface != watcherIface || !t.prop(m, prop, noReply) { | |
| 591 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property") | |
| 592 | + | } | |
| 593 | + | case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s": | |
| 594 | + | if noReply { | |
| 595 | + | return | |
| 596 | + | } | |
| 597 | + | e := t.c.NewReply(m, "a{sv}") | |
| 598 | + | a := e.BeginArray('{') | |
| 599 | + | if m.Body().Str() == watcherIface { | |
| 600 | + | for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} { | |
| 601 | + | e.Struct() | |
| 602 | + | e.Str(p) | |
| 603 | + | t.propValue(e, p) | |
| 604 | + | } | |
| 605 | + | } | |
| 606 | + | e.EndArray(a) | |
| 607 | + | t.send() | |
| 608 | + | case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect": | |
| 609 | + | if !noReply { | |
| 610 | + | t.c.NewReply(m, "s").Str(introspection) | |
| 611 | + | t.send() | |
| 612 | + | } | |
| 613 | + | case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping": | |
| 614 | + | if !noReply { | |
| 615 | + | t.c.NewReply(m, "") | |
| 616 | + | t.send() | |
| 617 | + | } | |
| 618 | + | default: | |
| 619 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method") | |
| 620 | + | } | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) { | |
| 624 | + | if !noReply { | |
| 625 | + | t.c.NewError(m, name, "s").Str(text) | |
| 626 | + | t.send() | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // prop answers Properties.Get; it reports false for an unknown property. | |
| 631 | + | func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool { | |
| 632 | + | switch name { | |
| 633 | + | case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion": | |
| 634 | + | default: | |
| 635 | + | return false | |
| 636 | + | } | |
| 637 | + | if !noReply { | |
| 638 | + | t.propValue(t.c.NewReply(m, "v"), name) | |
| 639 | + | t.send() | |
| 640 | + | } | |
| 641 | + | return true | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func (t *Tray) propValue(e *zerobus.Encoder, name string) { | |
| 645 | + | switch name { | |
| 646 | + | case "RegisteredStatusNotifierItems": | |
| 647 | + | e.Variant("as") | |
| 648 | + | a := e.BeginArray('s') | |
| 649 | + | for _, it := range t.items { | |
| 650 | + | e.Str(it.service) | |
| 651 | + | } | |
| 652 | + | e.EndArray(a) | |
| 653 | + | case "IsStatusNotifierHostRegistered": | |
| 654 | + | e.Variant("b") | |
| 655 | + | e.Bool(true) // this process is the host | |
| 656 | + | case "ProtocolVersion": | |
| 657 | + | e.Variant("i") | |
| 658 | + | e.Int32(0) | |
| 659 | + | } | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | func (t *Tray) renderOrFail() { | |
| 663 | + | if err := t.render(false); err != nil { | |
| 664 | + | t.fatal(err) | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // render writes the line of visible items when it differs from the last one. | |
| 669 | + | func (t *Tray) render(force bool) error { | |
| 670 | + | f := t.frame[:0] | |
| 671 | + | for _, it := range t.items { | |
| 672 | + | if !it.loaded || it.status == passive || it.icon == "" { | |
| 673 | + | continue | |
| 674 | + | } | |
| 675 | + | if len(f) > 0 { | |
| 676 | + | f = append(f, ' ') | |
| 677 | + | } | |
| 678 | + | c, bold := t.opt.Foreground, false | |
| 679 | + | switch { | |
| 680 | + | case it.status == attention: | |
| 681 | + | c, bold = t.opt.Accent, true | |
| 682 | + | case it.hasColor: | |
| 683 | + | c = it.color | |
| 684 | + | } | |
| 685 | + | f = append(f, "\x1b["...) | |
| 686 | + | if bold { | |
| 687 | + | f = append(f, "1;"...) | |
| 688 | + | } | |
| 689 | + | f = append(f, "38;2;"...) | |
| 690 | + | f = strconv.AppendUint(f, uint64(c[0]), 10) | |
| 691 | + | f = append(f, ';') | |
| 692 | + | f = strconv.AppendUint(f, uint64(c[1]), 10) | |
| 693 | + | f = append(f, ';') | |
| 694 | + | f = strconv.AppendUint(f, uint64(c[2]), 10) | |
| 695 | + | f = append(f, 'm') | |
| 696 | + | f = append(f, it.icon...) | |
| 697 | + | f = append(f, "\x1b[0m"...) | |
| 698 | + | } | |
| 699 | + | f = append(f, '\n') | |
| 700 | + | t.frame = f | |
| 701 | + | if !force && bytes.Equal(f, t.last) { | |
| 702 | + | return nil | |
| 703 | + | } | |
| 704 | + | t.frame, t.last = t.last, t.frame | |
| 705 | + | if _, err := t.opt.Out.Write(t.last); err != nil { | |
| 706 | + | return fmt.Errorf("tray: %w", err) | |
| 707 | + | } | |
| 708 | + | return nil | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" | |
| 712 | + | "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> | |
| 713 | + | <node> | |
| 714 | + | <interface name="org.kde.StatusNotifierWatcher"> | |
| 715 | + | <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method> | |
| 716 | + | <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method> | |
| 717 | + | <property name="RegisteredStatusNotifierItems" type="as" access="read"/> | |
| 718 | + | <property name="IsStatusNotifierHostRegistered" type="b" access="read"/> | |
| 719 | + | <property name="ProtocolVersion" type="i" access="read"/> | |
| 720 | + | <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal> | |
| 721 | + | <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal> | |
| 722 | + | <signal name="StatusNotifierHostRegistered"/> | |
| 723 | + | </interface> | |
| 724 | + | <interface name="org.freedesktop.DBus.Properties"> | |
| 725 | + | <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method> | |
| 726 | + | <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method> | |
| 727 | + | </interface> | |
| 728 | + | <interface name="org.freedesktop.DBus.Introspectable"> | |
| 729 | + | <method name="Introspect"><arg type="s" direction="out"/></method> | |
| 730 | + | </interface> | |
| 731 | + | </node> | |
| 732 | + | ` | |
| @@ -0,0 +1,732 @@ | |||||
| 1 | + | // Package tray shows the system tray (StatusNotifierItem) as one line of | |||
| 2 | + | // Nerd Font glyphs, for "neferbar tray". | |||
| 3 | + | // | |||
| 4 | + | // It acts as the StatusNotifierWatcher when no other program does, and as a | |||
| 5 | + | // plain host of the existing watcher otherwise, so it can run next to another | |||
| 6 | + | // panel. Everything runs on one goroutine around zerobus.Conn.ReadMessage; | |||
| 7 | + | // calls to applications never block it, as their replies are matched by | |||
| 8 | + | // serial when they arrive. | |||
| 9 | + | package tray | |||
| 10 | + | ||||
| 11 | + | import ( | |||
| 12 | + | "bytes" | |||
| 13 | + | "context" | |||
| 14 | + | "errors" | |||
| 15 | + | "fmt" | |||
| 16 | + | "io" | |||
| 17 | + | "log/slog" | |||
| 18 | + | "os" | |||
| 19 | + | "slices" | |||
| 20 | + | "strconv" | |||
| 21 | + | "strings" | |||
| 22 | + | ||||
| 23 | + | "github.com/bnema/zerobus" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | watcherName = "org.kde.StatusNotifierWatcher" | |||
| 28 | + | watcherPath = "/StatusNotifierWatcher" | |||
| 29 | + | watcherIface = "org.kde.StatusNotifierWatcher" | |||
| 30 | + | itemIface = "org.kde.StatusNotifierItem" | |||
| 31 | + | itemPath = "/StatusNotifierItem" | |||
| 32 | + | propsIface = "org.freedesktop.DBus.Properties" | |||
| 33 | + | busName = "org.freedesktop.DBus" | |||
| 34 | + | busPath = "/org/freedesktop/DBus" | |||
| 35 | + | ) | |||
| 36 | + | ||||
| 37 | + | // RGB is a color. | |||
| 38 | + | type RGB = [3]uint8 | |||
| 39 | + | ||||
| 40 | + | // Options configure a Tray. | |||
| 41 | + | type Options struct { | |||
| 42 | + | Resolver *Resolver | |||
| 43 | + | // Foreground colors items without an icon to take a color from; | |||
| 44 | + | // Background is what tinted icons must stand out against; Accent marks | |||
| 45 | + | // items that need attention. | |||
| 46 | + | Foreground, Background, Accent RGB | |||
| 47 | + | Out io.Writer // one line per change | |||
| 48 | + | Log *slog.Logger | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type status uint8 | |||
| 52 | + | ||||
| 53 | + | const ( | |||
| 54 | + | active status = iota | |||
| 55 | + | passive | |||
| 56 | + | attention | |||
| 57 | + | ) | |||
| 58 | + | ||||
| 59 | + | type item struct { | |||
| 60 | + | service string // as listed by the watcher: "bus/path" or "bus" | |||
| 61 | + | dest string // where to send calls: the owner, or the registered bus name | |||
| 62 | + | owner string // the unique name; signals come from it | |||
| 63 | + | path string | |||
| 64 | + | ||||
| 65 | + | id, iconName, title string | |||
| 66 | + | status status | |||
| 67 | + | icon string // resolved glyph; "" hides the item | |||
| 68 | + | resolved bool | |||
| 69 | + | color RGB | |||
| 70 | + | hasColor bool | |||
| 71 | + | loaded bool // GetAll answered at least once | |||
| 72 | + | pending uint32 // serial of a GetAll in flight | |||
| 73 | + | stale bool // a change arrived while GetAll was in flight | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | type pendKind uint8 | |||
| 77 | + | ||||
| 78 | + | const ( | |||
| 79 | + | pendGetAll pendKind = iota + 1 | |||
| 80 | + | pendItems // the watcher's RegisteredStatusNotifierItems | |||
| 81 | + | pendName // RequestName of the watcher name | |||
| 82 | + | pendIgnore | |||
| 83 | + | ) | |||
| 84 | + | ||||
| 85 | + | type pend struct { | |||
| 86 | + | serial uint32 | |||
| 87 | + | kind pendKind | |||
| 88 | + | it *item | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Tray is one running tray. | |||
| 92 | + | type Tray struct { | |||
| 93 | + | c *zerobus.Conn | |||
| 94 | + | opt Options | |||
| 95 | + | watcher bool // this process owns the watcher name | |||
| 96 | + | host string | |||
| 97 | + | items []*item | |||
| 98 | + | pending []pend | |||
| 99 | + | frame []byte | |||
| 100 | + | last []byte | |||
| 101 | + | hist histogram | |||
| 102 | + | err error // first fatal error from dispatch | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Run serves the tray on c until ctx ends or the bus connection fails. It | |||
| 106 | + | // closes c. | |||
| 107 | + | func Run(ctx context.Context, c *zerobus.Conn, opt Options) error { | |||
| 108 | + | t := &Tray{c: c, opt: opt, host: fmt.Sprintf("org.kde.StatusNotifierHost-%d", os.Getpid()), | |||
| 109 | + | frame: make([]byte, 0, 256), last: make([]byte, 0, 256)} | |||
| 110 | + | if t.opt.Log == nil { | |||
| 111 | + | t.opt.Log = slog.New(slog.DiscardHandler) | |||
| 112 | + | } | |||
| 113 | + | stop := context.AfterFunc(ctx, func() { _ = c.Close() }) | |||
| 114 | + | defer stop() | |||
| 115 | + | defer func() { _ = c.Close() }() | |||
| 116 | + | ||||
| 117 | + | // Calls made from here on only go to the bus, which always answers. | |||
| 118 | + | // Handle sees what arrives meanwhile. | |||
| 119 | + | c.Handle = t.dispatch | |||
| 120 | + | for _, rule := range [...]string{ | |||
| 121 | + | "type='signal',interface='" + itemIface + "'", | |||
| 122 | + | // The bus matches a well-known sender by its current owner. | |||
| 123 | + | "type='signal',sender='" + watcherName + "',interface='" + watcherIface + "'", | |||
| 124 | + | "type='signal',sender='" + busName + "',interface='" + busName + "',member='NameOwnerChanged'", | |||
| 125 | + | } { | |||
| 126 | + | if err := c.AddMatch(rule); err != nil { | |||
| 127 | + | return t.exit(ctx, fmt.Errorf("tray: add match: %w", err)) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | if _, err := c.RequestName(t.host, zerobus.NameFlagDoNotQueue); err != nil { | |||
| 131 | + | return t.exit(ctx, fmt.Errorf("tray: request host name: %w", err)) | |||
| 132 | + | } | |||
| 133 | + | t.claim() | |||
| 134 | + | if err := t.render(true); err != nil { | |||
| 135 | + | return t.exit(ctx, err) | |||
| 136 | + | } | |||
| 137 | + | for t.err == nil { | |||
| 138 | + | m, err := c.ReadMessage() | |||
| 139 | + | if err != nil { | |||
| 140 | + | return t.exit(ctx, err) | |||
| 141 | + | } | |||
| 142 | + | t.dispatch(m) | |||
| 143 | + | } | |||
| 144 | + | // A send fails too when ctx ends during dispatch and closes c. | |||
| 145 | + | return t.exit(ctx, t.err) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // exit returns nil when the error comes from ctx ending. | |||
| 149 | + | func (t *Tray) exit(ctx context.Context, err error) error { | |||
| 150 | + | if ctx.Err() != nil { | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (t *Tray) send() uint32 { | |||
| 157 | + | serial, err := t.c.Send() | |||
| 158 | + | if err != nil { | |||
| 159 | + | if errors.Is(err, zerobus.ErrInvalid) { | |||
| 160 | + | // A name or path an application gave us that D-Bus refuses. | |||
| 161 | + | t.opt.Log.Warn("tray: message not sent", "err", err) | |||
| 162 | + | return 0 | |||
| 163 | + | } | |||
| 164 | + | t.fatal(err) | |||
| 165 | + | return 0 | |||
| 166 | + | } | |||
| 167 | + | return serial | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (t *Tray) fatal(err error) { | |||
| 171 | + | if t.err == nil { | |||
| 172 | + | t.err = err | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (t *Tray) expect(serial uint32, kind pendKind, it *item) { | |||
| 177 | + | if serial != 0 { | |||
| 178 | + | t.pending = append(t.pending, pend{serial: serial, kind: kind, it: it}) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // claim asks for the watcher name; the reply decides the mode. | |||
| 183 | + | func (t *Tray) claim() { | |||
| 184 | + | e := t.c.NewCall(busName, busPath, busName, "RequestName", "su") | |||
| 185 | + | e.Str(watcherName) | |||
| 186 | + | e.Uint32(zerobus.NameFlagDoNotQueue) | |||
| 187 | + | t.expect(t.send(), pendName, nil) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (t *Tray) dispatch(m *zerobus.Message) { | |||
| 191 | + | switch m.Type { | |||
| 192 | + | case zerobus.TypeMethodReturn, zerobus.TypeError: | |||
| 193 | + | i := slices.IndexFunc(t.pending, func(p pend) bool { return p.serial == m.ReplySerial }) | |||
| 194 | + | if i < 0 { | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 197 | + | p := t.pending[i] | |||
| 198 | + | t.pending = slices.Delete(t.pending, i, i+1) | |||
| 199 | + | t.reply(p, m) | |||
| 200 | + | case zerobus.TypeSignal: | |||
| 201 | + | t.signal(m) | |||
| 202 | + | case zerobus.TypeMethodCall: | |||
| 203 | + | t.call(m) | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (t *Tray) reply(p pend, m *zerobus.Message) { | |||
| 208 | + | failed := m.Type == zerobus.TypeError | |||
| 209 | + | switch p.kind { | |||
| 210 | + | case pendName: | |||
| 211 | + | code := m.Body().Uint32() | |||
| 212 | + | if !failed && m.Signature == "u" && (code == zerobus.NamePrimaryOwner || code == zerobus.NameAlreadyOwner) { | |||
| 213 | + | t.becomeWatcher() | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | t.becomeHost() | |||
| 217 | + | case pendItems: | |||
| 218 | + | if failed { | |||
| 219 | + | t.opt.Log.Warn("tray: the watcher did not list its items", "err", m.ErrorName) | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | r := m.Body() | |||
| 223 | + | if m.Signature != "v" || r.Variant() != "as" { | |||
| 224 | + | t.opt.Log.Warn("tray: the watcher listed its items in an unknown form") | |||
| 225 | + | return | |||
| 226 | + | } | |||
| 227 | + | end := r.Array('s') | |||
| 228 | + | for r.More(end) { | |||
| 229 | + | t.add(r.Str(), "") | |||
| 230 | + | } | |||
| 231 | + | case pendGetAll: | |||
| 232 | + | it := p.it | |||
| 233 | + | if it.pending != m.ReplySerial || !slices.Contains(t.items, it) { | |||
| 234 | + | return // removed, or an older request | |||
| 235 | + | } | |||
| 236 | + | it.pending = 0 | |||
| 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) | |||
| 239 | + | if !it.loaded { | |||
| 240 | + | t.remove(it) | |||
| 241 | + | } | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | if it.owner != m.Sender { | |||
| 245 | + | it.owner = strings.Clone(m.Sender) | |||
| 246 | + | } | |||
| 247 | + | t.load(it, m.Body()) | |||
| 248 | + | if it.stale { | |||
| 249 | + | t.refresh(it) | |||
| 250 | + | } | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (t *Tray) becomeWatcher() { | |||
| 255 | + | t.watcher = true | |||
| 256 | + | t.opt.Log.Info("tray: acting as the StatusNotifierWatcher") | |||
| 257 | + | // Items that waited for a host register now. | |||
| 258 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierHostRegistered", "") | |||
| 259 | + | t.send() | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func (t *Tray) becomeHost() { | |||
| 263 | + | t.watcher = false | |||
| 264 | + | t.opt.Log.Info("tray: using the existing StatusNotifierWatcher") | |||
| 265 | + | t.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierHost", "s").Str(t.host) | |||
| 266 | + | t.expect(t.send(), pendIgnore, nil) | |||
| 267 | + | e := t.c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss") | |||
| 268 | + | e.Str(watcherIface) | |||
| 269 | + | e.Str("RegisteredStatusNotifierItems") | |||
| 270 | + | t.expect(t.send(), pendItems, nil) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func (t *Tray) signal(m *zerobus.Message) { | |||
| 274 | + | switch m.Interface { | |||
| 275 | + | case busName: | |||
| 276 | + | if m.Member != "NameOwnerChanged" || m.Sender != busName { | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | r := m.Body() | |||
| 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, not a late list from the old one. | |||
| 288 | + | t.pending = slices.DeleteFunc(t.pending, func(p pend) bool { return p.kind == pendItems }) | |||
| 289 | + | t.dropAll() | |||
| 290 | + | t.becomeHost() | |||
| 291 | + | } | |||
| 292 | + | case watcherIface: | |||
| 293 | + | if t.watcher || m.Signature != "s" { | |||
| 294 | + | return // our own signals come back to us too | |||
| 295 | + | } | |||
| 296 | + | s := m.Body().Str() | |||
| 297 | + | switch m.Member { | |||
| 298 | + | case "StatusNotifierItemRegistered": | |||
| 299 | + | t.add(s, "") | |||
| 300 | + | case "StatusNotifierItemUnregistered": | |||
| 301 | + | svc, _, _ := normService(s) | |||
| 302 | + | if i := slices.IndexFunc(t.items, func(it *item) bool { return it.service == svc }); i >= 0 { | |||
| 303 | + | t.remove(t.items[i]) | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | case itemIface: | |||
| 307 | + | switch m.Member { | |||
| 308 | + | case "NewToolTip", "NewMenu", "NewOverlayIcon": | |||
| 309 | + | return // nothing the tray shows | |||
| 310 | + | } | |||
| 311 | + | for _, it := range t.items { | |||
| 312 | + | if it.owner == m.Sender && it.path == m.Path { | |||
| 313 | + | t.refresh(it) | |||
| 314 | + | return | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | // vanished drops the items of a bus name that left, and takes over the | |||
| 321 | + | // watcher name when its owner left. | |||
| 322 | + | func (t *Tray) vanished(name string) { | |||
| 323 | + | changed := false | |||
| 324 | + | for i := len(t.items) - 1; i >= 0; i-- { | |||
| 325 | + | if it := t.items[i]; it.owner == name || it.dest == name { | |||
| 326 | + | t.drop(i) | |||
| 327 | + | changed = true | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | if changed { | |||
| 331 | + | t.renderOrFail() | |||
| 332 | + | } | |||
| 333 | + | if name == watcherName && !t.watcher { | |||
| 334 | + | // Items register again with the new watcher. | |||
| 335 | + | t.dropAll() | |||
| 336 | + | t.claim() | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func (t *Tray) dropAll() { | |||
| 341 | + | if len(t.items) == 0 { | |||
| 342 | + | return | |||
| 343 | + | } | |||
| 344 | + | for i := len(t.items) - 1; i >= 0; i-- { | |||
| 345 | + | t.drop(i) | |||
| 346 | + | } | |||
| 347 | + | t.renderOrFail() | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // normService turns the watcher's form of an item, "bus/path" or "bus", into | |||
| 351 | + | // "bus/path", its bus and its path. | |||
| 352 | + | func normService(s string) (service, bus, path string) { | |||
| 353 | + | bus, p, ok := strings.Cut(s, "/") | |||
| 354 | + | if !ok { | |||
| 355 | + | return bus + itemPath, bus, itemPath | |||
| 356 | + | } | |||
| 357 | + | return s, bus, "/" + p | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | // validBusName reports whether s is a unique (":1.42") or well-known | |||
| 361 | + | // ("org.example.App") bus name, as D-Bus defines them. | |||
| 362 | + | func validBusName(s string) bool { | |||
| 363 | + | if s == "" || len(s) > 255 { | |||
| 364 | + | return false | |||
| 365 | + | } | |||
| 366 | + | unique := s[0] == ':' | |||
| 367 | + | if unique { | |||
| 368 | + | s = s[1:] | |||
| 369 | + | } | |||
| 370 | + | n := 0 | |||
| 371 | + | for more := true; more; n++ { | |||
| 372 | + | var e string | |||
| 373 | + | e, s, more = strings.Cut(s, ".") | |||
| 374 | + | if e == "" || !unique && e[0] >= '0' && e[0] <= '9' { | |||
| 375 | + | return false | |||
| 376 | + | } | |||
| 377 | + | for _, c := range []byte(e) { | |||
| 378 | + | if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | return n >= 2 | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // addResult says what add did. | |||
| 387 | + | type addResult uint8 | |||
| 388 | + | ||||
| 389 | + | const ( | |||
| 390 | + | addNew addResult = iota | |||
| 391 | + | addKnown | |||
| 392 | + | addInvalid // D-Bus refuses its bus name or path | |||
| 393 | + | ) | |||
| 394 | + | ||||
| 395 | + | // add starts tracking an item. service is the watcher's form, "bus/path" or | |||
| 396 | + | // "bus"; owner is its unique name when known. | |||
| 397 | + | func (t *Tray) add(service, owner string) addResult { | |||
| 398 | + | service, dest, path := normService(service) | |||
| 399 | + | if owner == "" && strings.HasPrefix(dest, ":") { | |||
| 400 | + | owner = dest | |||
| 401 | + | } | |||
| 402 | + | for _, it := range t.items { | |||
| 403 | + | if it.service == service || owner != "" && it.owner == owner && it.path == path { | |||
| 404 | + | return addKnown | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | it := &item{service: strings.Clone(service), dest: strings.Clone(dest), owner: strings.Clone(owner), path: strings.Clone(path)} | |||
| 408 | + | t.items = append(t.items, it) | |||
| 409 | + | t.refresh(it) | |||
| 410 | + | if it.pending == 0 { | |||
| 411 | + | // Never shown nor announced: forget it quietly. | |||
| 412 | + | t.items = t.items[:len(t.items)-1] | |||
| 413 | + | return addInvalid | |||
| 414 | + | } | |||
| 415 | + | return addNew | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | func (t *Tray) remove(it *item) { | |||
| 419 | + | if i := slices.Index(t.items, it); i >= 0 { | |||
| 420 | + | t.drop(i) | |||
| 421 | + | t.renderOrFail() | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func (t *Tray) drop(i int) { | |||
| 426 | + | it := t.items[i] | |||
| 427 | + | t.items = slices.Delete(t.items, i, i+1) | |||
| 428 | + | if t.watcher { | |||
| 429 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemUnregistered", "s").Str(it.service) | |||
| 430 | + | t.send() | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | // refresh asks an item for its properties, unless a request is in flight. | |||
| 435 | + | func (t *Tray) refresh(it *item) { | |||
| 436 | + | if it.pending != 0 { | |||
| 437 | + | it.stale = true | |||
| 438 | + | return | |||
| 439 | + | } | |||
| 440 | + | it.stale = false | |||
| 441 | + | dest := it.dest | |||
| 442 | + | if it.owner != "" { | |||
| 443 | + | dest = it.owner | |||
| 444 | + | } | |||
| 445 | + | t.c.NewCall(dest, it.path, propsIface, "GetAll", "s").Str(itemIface) | |||
| 446 | + | it.pending = t.send() | |||
| 447 | + | t.expect(it.pending, pendGetAll, it) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // load reads a GetAll reply into it and redraws when something shown | |||
| 451 | + | // changed. Strings are copied only when they change. | |||
| 452 | + | func (t *Tray) load(it *item, r *zerobus.Reader) { | |||
| 453 | + | was := *it | |||
| 454 | + | var pixmaps, attentionPixmaps []byte // the chosen pixmap of each kind | |||
| 455 | + | end := r.Array('{') | |||
| 456 | + | for r.More(end) { | |||
| 457 | + | r.Struct() | |||
| 458 | + | key := r.Str() | |||
| 459 | + | sig := r.Variant() | |||
| 460 | + | switch { | |||
| 461 | + | case sig == "s" && key == "Id": | |||
| 462 | + | setStr(&it.id, r.Str()) | |||
| 463 | + | case sig == "s" && key == "Title": | |||
| 464 | + | setStr(&it.title, r.Str()) | |||
| 465 | + | case sig == "s" && key == "IconName": | |||
| 466 | + | setStr(&it.iconName, r.Str()) | |||
| 467 | + | case sig == "s" && key == "Status": | |||
| 468 | + | it.status = parseStatus(r.Str()) | |||
| 469 | + | case sig == "a(iiay)" && key == "IconPixmap": | |||
| 470 | + | pixmaps = pickPixmap(r) | |||
| 471 | + | case sig == "a(iiay)" && key == "AttentionIconPixmap": | |||
| 472 | + | attentionPixmaps = pickPixmap(r) | |||
| 473 | + | default: | |||
| 474 | + | r.Skip(sig) | |||
| 475 | + | } | |||
| 476 | + | } | |||
| 477 | + | if r.Err() != nil { | |||
| 478 | + | t.opt.Log.Warn("tray: bad properties", "item", it.service, "err", r.Err()) | |||
| 479 | + | *it = was | |||
| 480 | + | return | |||
| 481 | + | } | |||
| 482 | + | it.loaded = true | |||
| 483 | + | if it.status == attention && attentionPixmaps != nil { | |||
| 484 | + | pixmaps = attentionPixmaps | |||
| 485 | + | } | |||
| 486 | + | it.color, it.hasColor = t.hist.dominant(pixmaps) | |||
| 487 | + | if it.hasColor { | |||
| 488 | + | it.color = readable(it.color, t.opt.Background, t.opt.Foreground) | |||
| 489 | + | } | |||
| 490 | + | if !it.resolved || it.id != was.id || it.iconName != was.iconName || it.title != was.title { | |||
| 491 | + | it.icon, it.resolved = t.opt.Resolver.Resolve(it.id, it.iconName, it.title), true | |||
| 492 | + | } | |||
| 493 | + | t.renderOrFail() | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // pickPixmap reads an a(iiay) value and returns the ARGB data of the pixmap | |||
| 497 | + | // best suited to find a color: the largest up to 64x64, else the smallest. | |||
| 498 | + | // The result points into the message buffer. | |||
| 499 | + | func pickPixmap(r *zerobus.Reader) []byte { | |||
| 500 | + | var best []byte | |||
| 501 | + | bestN := 0 | |||
| 502 | + | end := r.Array('(') | |||
| 503 | + | for r.More(end) { | |||
| 504 | + | r.Struct() | |||
| 505 | + | w, h := int(r.Int32()), int(r.Int32()) | |||
| 506 | + | data := r.ByteArray() | |||
| 507 | + | n := w * h | |||
| 508 | + | if w <= 0 || h <= 0 || w > 1024 || h > 1024 || len(data) != 4*n { | |||
| 509 | + | continue | |||
| 510 | + | } | |||
| 511 | + | if best == nil || better(n, bestN) { | |||
| 512 | + | best, bestN = data, n | |||
| 513 | + | } | |||
| 514 | + | } | |||
| 515 | + | return best | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // better reports whether an icon of n pixels suits the color search better | |||
| 519 | + | // than one of cur pixels: the largest up to 64x64, else the smallest. | |||
| 520 | + | func better(n, cur int) bool { | |||
| 521 | + | const enough = 64 * 64 | |||
| 522 | + | if cur > enough { | |||
| 523 | + | return n < cur | |||
| 524 | + | } | |||
| 525 | + | return n > cur && n <= enough | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | func setStr(dst *string, s string) { | |||
| 529 | + | if *dst != s { | |||
| 530 | + | *dst = strings.Clone(s) | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | func parseStatus(s string) status { | |||
| 535 | + | switch s { | |||
| 536 | + | case "Passive": | |||
| 537 | + | return passive | |||
| 538 | + | case "NeedsAttention": | |||
| 539 | + | return attention | |||
| 540 | + | } | |||
| 541 | + | return active | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | // call answers method calls when this process is the watcher. | |||
| 545 | + | func (t *Tray) call(m *zerobus.Message) { | |||
| 546 | + | noReply := m.Flags&zerobus.FlagNoReplyExpected != 0 | |||
| 547 | + | if !t.watcher || m.Path != watcherPath { | |||
| 548 | + | if !noReply { | |||
| 549 | + | t.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no such object") | |||
| 550 | + | t.send() | |||
| 551 | + | } | |||
| 552 | + | return | |||
| 553 | + | } | |||
| 554 | + | switch { | |||
| 555 | + | case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierItem" && m.Signature == "s": | |||
| 556 | + | s := m.Body().Str() | |||
| 557 | + | if s == "" { | |||
| 558 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "empty service") | |||
| 559 | + | return | |||
| 560 | + | } | |||
| 561 | + | if strings.HasPrefix(s, "/") { | |||
| 562 | + | s = m.Sender + s // registered by object path | |||
| 563 | + | } else if validBusName(s) { | |||
| 564 | + | s += itemPath | |||
| 565 | + | } else { | |||
| 566 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service") | |||
| 567 | + | return | |||
| 568 | + | } | |||
| 569 | + | // add copies s before the next read, and sends only a GetAll. | |||
| 570 | + | switch t.add(s, m.Sender) { | |||
| 571 | + | case addInvalid: | |||
| 572 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.InvalidArgs", "invalid service") | |||
| 573 | + | return | |||
| 574 | + | case addNew: | |||
| 575 | + | t.c.NewSignal(watcherPath, watcherIface, "StatusNotifierItemRegistered", "s").Str(t.items[len(t.items)-1].service) | |||
| 576 | + | t.send() | |||
| 577 | + | } | |||
| 578 | + | if !noReply { | |||
| 579 | + | t.c.NewReply(m, "") | |||
| 580 | + | t.send() | |||
| 581 | + | } | |||
| 582 | + | case m.Interface == watcherIface && m.Member == "RegisterStatusNotifierHost": | |||
| 583 | + | if !noReply { | |||
| 584 | + | t.c.NewReply(m, "") | |||
| 585 | + | t.send() | |||
| 586 | + | } | |||
| 587 | + | case m.Interface == propsIface && m.Member == "Get" && m.Signature == "ss": | |||
| 588 | + | r := m.Body() | |||
| 589 | + | iface, prop := r.Str(), r.Str() | |||
| 590 | + | if iface != watcherIface || !t.prop(m, prop, noReply) { | |||
| 591 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownProperty", "no such property") | |||
| 592 | + | } | |||
| 593 | + | case m.Interface == propsIface && m.Member == "GetAll" && m.Signature == "s": | |||
| 594 | + | if noReply { | |||
| 595 | + | return | |||
| 596 | + | } | |||
| 597 | + | e := t.c.NewReply(m, "a{sv}") | |||
| 598 | + | a := e.BeginArray('{') | |||
| 599 | + | if m.Body().Str() == watcherIface { | |||
| 600 | + | for _, p := range [...]string{"RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion"} { | |||
| 601 | + | e.Struct() | |||
| 602 | + | e.Str(p) | |||
| 603 | + | t.propValue(e, p) | |||
| 604 | + | } | |||
| 605 | + | } | |||
| 606 | + | e.EndArray(a) | |||
| 607 | + | t.send() | |||
| 608 | + | case m.Interface == "org.freedesktop.DBus.Introspectable" && m.Member == "Introspect": | |||
| 609 | + | if !noReply { | |||
| 610 | + | t.c.NewReply(m, "s").Str(introspection) | |||
| 611 | + | t.send() | |||
| 612 | + | } | |||
| 613 | + | case m.Interface == "org.freedesktop.DBus.Peer" && m.Member == "Ping": | |||
| 614 | + | if !noReply { | |||
| 615 | + | t.c.NewReply(m, "") | |||
| 616 | + | t.send() | |||
| 617 | + | } | |||
| 618 | + | default: | |||
| 619 | + | t.errorReply(m, noReply, "org.freedesktop.DBus.Error.UnknownMethod", "no such method") | |||
| 620 | + | } | |||
| 621 | + | } | |||
| 622 | + | ||||
| 623 | + | func (t *Tray) errorReply(m *zerobus.Message, noReply bool, name, text string) { | |||
| 624 | + | if !noReply { | |||
| 625 | + | t.c.NewError(m, name, "s").Str(text) | |||
| 626 | + | t.send() | |||
| 627 | + | } | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // prop answers Properties.Get; it reports false for an unknown property. | |||
| 631 | + | func (t *Tray) prop(m *zerobus.Message, name string, noReply bool) bool { | |||
| 632 | + | switch name { | |||
| 633 | + | case "RegisteredStatusNotifierItems", "IsStatusNotifierHostRegistered", "ProtocolVersion": | |||
| 634 | + | default: | |||
| 635 | + | return false | |||
| 636 | + | } | |||
| 637 | + | if !noReply { | |||
| 638 | + | t.propValue(t.c.NewReply(m, "v"), name) | |||
| 639 | + | t.send() | |||
| 640 | + | } | |||
| 641 | + | return true | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func (t *Tray) propValue(e *zerobus.Encoder, name string) { | |||
| 645 | + | switch name { | |||
| 646 | + | case "RegisteredStatusNotifierItems": | |||
| 647 | + | e.Variant("as") | |||
| 648 | + | a := e.BeginArray('s') | |||
| 649 | + | for _, it := range t.items { | |||
| 650 | + | e.Str(it.service) | |||
| 651 | + | } | |||
| 652 | + | e.EndArray(a) | |||
| 653 | + | case "IsStatusNotifierHostRegistered": | |||
| 654 | + | e.Variant("b") | |||
| 655 | + | e.Bool(true) // this process is the host | |||
| 656 | + | case "ProtocolVersion": | |||
| 657 | + | e.Variant("i") | |||
| 658 | + | e.Int32(0) | |||
| 659 | + | } | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | func (t *Tray) renderOrFail() { | |||
| 663 | + | if err := t.render(false); err != nil { | |||
| 664 | + | t.fatal(err) | |||
| 665 | + | } | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // render writes the line of visible items when it differs from the last one. | |||
| 669 | + | func (t *Tray) render(force bool) error { | |||
| 670 | + | f := t.frame[:0] | |||
| 671 | + | for _, it := range t.items { | |||
| 672 | + | if !it.loaded || it.status == passive || it.icon == "" { | |||
| 673 | + | continue | |||
| 674 | + | } | |||
| 675 | + | if len(f) > 0 { | |||
| 676 | + | f = append(f, ' ') | |||
| 677 | + | } | |||
| 678 | + | c, bold := t.opt.Foreground, false | |||
| 679 | + | switch { | |||
| 680 | + | case it.status == attention: | |||
| 681 | + | c, bold = t.opt.Accent, true | |||
| 682 | + | case it.hasColor: | |||
| 683 | + | c = it.color | |||
| 684 | + | } | |||
| 685 | + | f = append(f, "\x1b["...) | |||
| 686 | + | if bold { | |||
| 687 | + | f = append(f, "1;"...) | |||
| 688 | + | } | |||
| 689 | + | f = append(f, "38;2;"...) | |||
| 690 | + | f = strconv.AppendUint(f, uint64(c[0]), 10) | |||
| 691 | + | f = append(f, ';') | |||
| 692 | + | f = strconv.AppendUint(f, uint64(c[1]), 10) | |||
| 693 | + | f = append(f, ';') | |||
| 694 | + | f = strconv.AppendUint(f, uint64(c[2]), 10) | |||
| 695 | + | f = append(f, 'm') | |||
| 696 | + | f = append(f, it.icon...) | |||
| 697 | + | f = append(f, "\x1b[0m"...) | |||
| 698 | + | } | |||
| 699 | + | f = append(f, '\n') | |||
| 700 | + | t.frame = f | |||
| 701 | + | if !force && bytes.Equal(f, t.last) { | |||
| 702 | + | return nil | |||
| 703 | + | } | |||
| 704 | + | t.frame, t.last = t.last, t.frame | |||
| 705 | + | if _, err := t.opt.Out.Write(t.last); err != nil { | |||
| 706 | + | return fmt.Errorf("tray: %w", err) | |||
| 707 | + | } | |||
| 708 | + | return nil | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | const introspection = `<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" | |||
| 712 | + | "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd"> | |||
| 713 | + | <node> | |||
| 714 | + | <interface name="org.kde.StatusNotifierWatcher"> | |||
| 715 | + | <method name="RegisterStatusNotifierItem"><arg name="service" type="s" direction="in"/></method> | |||
| 716 | + | <method name="RegisterStatusNotifierHost"><arg name="service" type="s" direction="in"/></method> | |||
| 717 | + | <property name="RegisteredStatusNotifierItems" type="as" access="read"/> | |||
| 718 | + | <property name="IsStatusNotifierHostRegistered" type="b" access="read"/> | |||
| 719 | + | <property name="ProtocolVersion" type="i" access="read"/> | |||
| 720 | + | <signal name="StatusNotifierItemRegistered"><arg type="s"/></signal> | |||
| 721 | + | <signal name="StatusNotifierItemUnregistered"><arg type="s"/></signal> | |||
| 722 | + | <signal name="StatusNotifierHostRegistered"/> | |||
| 723 | + | </interface> | |||
| 724 | + | <interface name="org.freedesktop.DBus.Properties"> | |||
| 725 | + | <method name="Get"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="v" direction="out"/></method> | |||
| 726 | + | <method name="GetAll"><arg type="s" direction="in"/><arg type="a{sv}" direction="out"/></method> | |||
| 727 | + | </interface> | |||
| 728 | + | <interface name="org.freedesktop.DBus.Introspectable"> | |||
| 729 | + | <method name="Introspect"><arg type="s" direction="out"/></method> | |||
| 730 | + | </interface> | |||
| 731 | + | </node> | |||
| 732 | + | ` | |||
A internal/tray/tray_test.go +542 -0
| @@ -0,0 +1,542 @@ | |||
| 1 | + | package tray | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "log/slog" | |
| 8 | + | "os/exec" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerobus" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // privateBus starts a dbus-daemon for the test and returns its address. | |
| 17 | + | func privateBus(t *testing.T) string { | |
| 18 | + | t.Helper() | |
| 19 | + | path, err := exec.LookPath("dbus-daemon") | |
| 20 | + | if err != nil { | |
| 21 | + | t.Skip("dbus-daemon not installed") | |
| 22 | + | } | |
| 23 | + | cmd := exec.Command(path, "--session", "--nofork", "--print-address") | |
| 24 | + | out, err := cmd.StdoutPipe() | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if err := cmd.Start(); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | t.Cleanup(func() { | |
| 32 | + | _ = cmd.Process.Kill() | |
| 33 | + | _ = cmd.Wait() | |
| 34 | + | }) | |
| 35 | + | addr, err := bufio.NewReader(out).ReadString('\n') | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | return strings.TrimSpace(addr) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func dial(t *testing.T, addr string) *zerobus.Conn { | |
| 43 | + | t.Helper() | |
| 44 | + | c, err := zerobus.Dial(addr) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | t.Cleanup(func() { _ = c.Close() }) | |
| 49 | + | return c | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // fakeItem is an application with one StatusNotifierItem. Its connection is | |
| 53 | + | // used only by its own goroutine, as a Conn requires: the test drives it | |
| 54 | + | // through a peer that calls its Do method. | |
| 55 | + | type fakeItem struct { | |
| 56 | + | c *zerobus.Conn | |
| 57 | + | name string | |
| 58 | + | driver *zerobus.Conn | |
| 59 | + | cmds chan func() | |
| 60 | + | cur [3]string // id, icon name, status | |
| 61 | + | pixel [4]byte // the one ARGB pixel of its icon | |
| 62 | + | closed chan struct{} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | |
| 66 | + | c := dial(t, addr) | |
| 67 | + | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | |
| 68 | + | cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})} | |
| 69 | + | go f.serve() | |
| 70 | + | return f | |
| 71 | + | } | |
| 72 | + | ||
| 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. | |
| 75 | + | func (f *fakeItem) run(t *testing.T, fn func()) { | |
| 76 | + | t.Helper() | |
| 77 | + | f.cmds <- fn | |
| 78 | + | f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "") | |
| 79 | + | if _, err := f.driver.Call(); err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (f *fakeItem) serve() { | |
| 85 | + | defer close(f.closed) | |
| 86 | + | for { | |
| 87 | + | m, err := f.c.ReadMessage() | |
| 88 | + | if err != nil { | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | if m.Type == zerobus.TypeMethodCall && m.Member == "Do" { | |
| 92 | + | f.c.NewReply(m, "") | |
| 93 | + | if _, err := f.c.Send(); err != nil { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | (<-f.cmds)() | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | e := f.c.NewReply(m, "a{sv}") | |
| 103 | + | a := e.BeginArray('{') | |
| 104 | + | for i, k := range [...]string{"Id", "IconName", "Status"} { | |
| 105 | + | e.Struct() | |
| 106 | + | e.Str(k) | |
| 107 | + | e.Variant("s") | |
| 108 | + | e.Str(f.cur[i]) | |
| 109 | + | } | |
| 110 | + | e.Struct() | |
| 111 | + | e.Str("IconPixmap") | |
| 112 | + | e.Variant("a(iiay)") | |
| 113 | + | px := e.BeginArray('(') | |
| 114 | + | e.Struct() | |
| 115 | + | e.Int32(1) | |
| 116 | + | e.Int32(1) | |
| 117 | + | e.ByteArray(f.pixel[:]) | |
| 118 | + | e.EndArray(px) | |
| 119 | + | e.EndArray(a) | |
| 120 | + | if _, err := f.c.Send(); err != nil { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // register registers the item with the watcher, by bus name. | |
| 127 | + | func (f *fakeItem) register(t *testing.T) { | |
| 128 | + | f.run(t, func() { | |
| 129 | + | f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name) | |
| 130 | + | _, _ = f.c.Send() | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // setStatus changes the item's status and emits NewStatus. | |
| 135 | + | func (f *fakeItem) setStatus(t *testing.T, status string) { | |
| 136 | + | f.run(t, func() { | |
| 137 | + | f.cur[2] = status | |
| 138 | + | f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status) | |
| 139 | + | _, _ = f.c.Send() | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // quit closes the item's connection, as when the application exits. | |
| 144 | + | func (f *fakeItem) quit(t *testing.T) { | |
| 145 | + | f.run(t, func() { _ = f.c.Close() }) | |
| 146 | + | <-f.closed | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // lines reads the tray's output. | |
| 150 | + | type lines struct { | |
| 151 | + | ch chan string | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func readLines(r io.Reader) *lines { | |
| 155 | + | l := &lines{ch: make(chan string, 64)} | |
| 156 | + | go func() { | |
| 157 | + | sc := bufio.NewScanner(r) | |
| 158 | + | for sc.Scan() { | |
| 159 | + | l.ch <- sc.Text() | |
| 160 | + | } | |
| 161 | + | close(l.ch) | |
| 162 | + | }() | |
| 163 | + | return l | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // waitFor returns the first line for which ok is true. | |
| 167 | + | func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string { | |
| 168 | + | t.Helper() | |
| 169 | + | timeout := time.After(5 * time.Second) | |
| 170 | + | for { | |
| 171 | + | select { | |
| 172 | + | case s, open := <-l.ch: | |
| 173 | + | if !open { | |
| 174 | + | t.Fatalf("output ended while waiting for %s", what) | |
| 175 | + | } | |
| 176 | + | if ok(s) { | |
| 177 | + | return s | |
| 178 | + | } | |
| 179 | + | case <-timeout: | |
| 180 | + | t.Fatalf("timed out waiting for %s", what) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // startTray runs a tray on the bus. Cancel it and receive from the channel | |
| 186 | + | // to stop it; the test cleanup does both when the test did not. | |
| 187 | + | func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) { | |
| 188 | + | pr, pw := io.Pipe() | |
| 189 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 190 | + | done := make(chan error, 1) | |
| 191 | + | var result error | |
| 192 | + | stopped := make(chan struct{}) | |
| 193 | + | opt := Options{ | |
| 194 | + | Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), | |
| 195 | + | Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250}, | |
| 196 | + | Out: pw, | |
| 197 | + | } | |
| 198 | + | c := dial(t, addr) | |
| 199 | + | go func() { | |
| 200 | + | result = Run(ctx, c, opt) | |
| 201 | + | _ = pw.Close() | |
| 202 | + | close(stopped) | |
| 203 | + | done <- result | |
| 204 | + | }() | |
| 205 | + | t.Cleanup(func() { | |
| 206 | + | cancel() | |
| 207 | + | <-stopped | |
| 208 | + | }) | |
| 209 | + | return readLines(pr), cancel, done | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestTrayAsWatcher(t *testing.T) { | |
| 213 | + | addr := privateBus(t) | |
| 214 | + | out, cancel, done := startTray(t, addr) | |
| 215 | + | if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" { | |
| 216 | + | t.Fatalf("first line %q, want empty", s) | |
| 217 | + | } | |
| 218 | + | // Wait until the tray owns the watcher name. | |
| 219 | + | probe := dial(t, addr) | |
| 220 | + | deadline := time.Now().Add(5 * time.Second) | |
| 221 | + | for { | |
| 222 | + | probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName) | |
| 223 | + | m, err := probe.Call() | |
| 224 | + | if err != nil { | |
| 225 | + | t.Fatal(err) | |
| 226 | + | } | |
| 227 | + | if m.Body().Bool() { | |
| 228 | + | break | |
| 229 | + | } | |
| 230 | + | if time.Now().After(deadline) { | |
| 231 | + | t.Fatal("the tray did not become the watcher") | |
| 232 | + | } | |
| 233 | + | time.Sleep(10 * time.Millisecond) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |
| 237 | + | steam.register(t) | |
| 238 | + | line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") }) | |
| 239 | + | if !strings.Contains(line, "38;2;") { | |
| 240 | + | t.Fatalf("the icon is not colored: %q", line) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | steam.setStatus(t, "NeedsAttention") | |
| 244 | + | out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") }) | |
| 245 | + | ||
| 246 | + | steam.setStatus(t, "Passive") | |
| 247 | + | out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" }) | |
| 248 | + | ||
| 249 | + | steam.setStatus(t, "Active") | |
| 250 | + | out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") }) | |
| 251 | + | ||
| 252 | + | // The application quits: its item goes away. | |
| 253 | + | steam.quit(t) | |
| 254 | + | out.waitFor(t, "the item to go away", func(s string) bool { return s == "" }) | |
| 255 | + | ||
| 256 | + | cancel() | |
| 257 | + | if err := <-done; err != nil { | |
| 258 | + | t.Fatalf("Run: %v", err) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 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 { | |
| 298 | + | t.Fatalf("RequestName = %d, %v", code, err) | |
| 299 | + | } | |
| 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 { | |
| 318 | + | return | |
| 319 | + | } | |
| 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) | |
| 328 | + | } | |
| 329 | + | e.EndArray(a) | |
| 330 | + | default: | |
| 331 | + | w.c.NewReply(m, "") | |
| 332 | + | } | |
| 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) | |
| 366 | + | ||
| 367 | + | out, _, _ := startTray(t, addr) | |
| 368 | + | out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") }) | |
| 369 | + | ||
| 370 | + | // A second item is announced by the other watcher, by bus name only. | |
| 371 | + | steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |
| 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 { | |
| 422 | + | t.Fatal(err) | |
| 423 | + | } | |
| 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 | + | for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} { | |
| 462 | + | probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad) | |
| 463 | + | if _, err := probe.Call(); err == nil { | |
| 464 | + | t.Fatalf("%q was accepted", bad) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath { | |
| 468 | + | t.Fatalf("registered items %q", items) | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // TestRefreshNoAllocs checks the steady state: an item signals a change, the | |
| 473 | + | // tray asks for its properties and reads the reply. Nothing allocates once | |
| 474 | + | // the buffers have grown. | |
| 475 | + | func TestRefreshNoAllocs(t *testing.T) { | |
| 476 | + | addr := privateBus(t) | |
| 477 | + | app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |
| 478 | + | var out strings.Builder | |
| 479 | + | tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out}, | |
| 480 | + | frame: make([]byte, 0, 256), last: make([]byte, 0, 256)} | |
| 481 | + | tr.opt.Log = slog.New(slog.DiscardHandler) | |
| 482 | + | tr.add(app.name, "") | |
| 483 | + | cycle := func() { | |
| 484 | + | tr.refresh(tr.items[0]) | |
| 485 | + | for len(tr.pending) > 0 { | |
| 486 | + | m, err := tr.c.ReadMessage() | |
| 487 | + | if err != nil { | |
| 488 | + | t.Fatal(err) | |
| 489 | + | } | |
| 490 | + | tr.dispatch(m) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | for range 3 { | |
| 494 | + | cycle() // resolve the icon and grow the buffers | |
| 495 | + | } | |
| 496 | + | if tr.err != nil || !tr.items[0].loaded { | |
| 497 | + | t.Fatalf("item not loaded: %v", tr.err) | |
| 498 | + | } | |
| 499 | + | if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 { | |
| 500 | + | t.Fatalf("%v allocations per refresh, want 0", allocs) | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestRenderSkipsUnchangedFrames(t *testing.T) { | |
| 505 | + | var buf strings.Builder | |
| 506 | + | tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}} | |
| 507 | + | tr.items = []*item{{loaded: true, icon: "X"}} | |
| 508 | + | for range 3 { | |
| 509 | + | if err := tr.render(false); err != nil { | |
| 510 | + | t.Fatal(err) | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | if got := strings.Count(buf.String(), "\n"); got != 1 { | |
| 514 | + | t.Fatalf("%d frames written, want 1", got) | |
| 515 | + | } | |
| 516 | + | if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 { | |
| 517 | + | t.Fatalf("render allocates %v times", allocs) | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func TestBetterPixmapSize(t *testing.T) { | |
| 522 | + | // The largest icon up to 64x64 wins; above that, the smallest. | |
| 523 | + | for _, c := range []struct { | |
| 524 | + | sizes []int // sides, in the order the item lists them | |
| 525 | + | want int | |
| 526 | + | }{ | |
| 527 | + | {[]int{16, 32, 128}, 32}, | |
| 528 | + | {[]int{128, 32, 16}, 32}, | |
| 529 | + | {[]int{256, 128}, 128}, | |
| 530 | + | {[]int{22}, 22}, | |
| 531 | + | } { | |
| 532 | + | best := 0 | |
| 533 | + | for _, s := range c.sizes { | |
| 534 | + | if best == 0 || better(s*s, best*best) { | |
| 535 | + | best = s | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | if best != c.want { | |
| 539 | + | t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want) | |
| 540 | + | } | |
| 541 | + | } | |
| 542 | + | } | |
| @@ -0,0 +1,542 @@ | |||||
| 1 | + | package tray | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerobus" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // privateBus starts a dbus-daemon for the test and returns its address. | |||
| 17 | + | func privateBus(t *testing.T) string { | |||
| 18 | + | t.Helper() | |||
| 19 | + | path, err := exec.LookPath("dbus-daemon") | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Skip("dbus-daemon not installed") | |||
| 22 | + | } | |||
| 23 | + | cmd := exec.Command(path, "--session", "--nofork", "--print-address") | |||
| 24 | + | out, err := cmd.StdoutPipe() | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | if err := cmd.Start(); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | t.Cleanup(func() { | |||
| 32 | + | _ = cmd.Process.Kill() | |||
| 33 | + | _ = cmd.Wait() | |||
| 34 | + | }) | |||
| 35 | + | addr, err := bufio.NewReader(out).ReadString('\n') | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | return strings.TrimSpace(addr) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func dial(t *testing.T, addr string) *zerobus.Conn { | |||
| 43 | + | t.Helper() | |||
| 44 | + | c, err := zerobus.Dial(addr) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 49 | + | return c | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // fakeItem is an application with one StatusNotifierItem. Its connection is | |||
| 53 | + | // used only by its own goroutine, as a Conn requires: the test drives it | |||
| 54 | + | // through a peer that calls its Do method. | |||
| 55 | + | type fakeItem struct { | |||
| 56 | + | c *zerobus.Conn | |||
| 57 | + | name string | |||
| 58 | + | driver *zerobus.Conn | |||
| 59 | + | cmds chan func() | |||
| 60 | + | cur [3]string // id, icon name, status | |||
| 61 | + | pixel [4]byte // the one ARGB pixel of its icon | |||
| 62 | + | closed chan struct{} | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem { | |||
| 66 | + | c := dial(t, addr) | |||
| 67 | + | f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1), | |||
| 68 | + | cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{})} | |||
| 69 | + | go f.serve() | |||
| 70 | + | return f | |||
| 71 | + | } | |||
| 72 | + | ||||
| 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. | |||
| 75 | + | func (f *fakeItem) run(t *testing.T, fn func()) { | |||
| 76 | + | t.Helper() | |||
| 77 | + | f.cmds <- fn | |||
| 78 | + | f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "") | |||
| 79 | + | if _, err := f.driver.Call(); err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func (f *fakeItem) serve() { | |||
| 85 | + | defer close(f.closed) | |||
| 86 | + | for { | |||
| 87 | + | m, err := f.c.ReadMessage() | |||
| 88 | + | if err != nil { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | if m.Type == zerobus.TypeMethodCall && m.Member == "Do" { | |||
| 92 | + | f.c.NewReply(m, "") | |||
| 93 | + | if _, err := f.c.Send(); err != nil { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | (<-f.cmds)() | |||
| 97 | + | continue | |||
| 98 | + | } | |||
| 99 | + | if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | e := f.c.NewReply(m, "a{sv}") | |||
| 103 | + | a := e.BeginArray('{') | |||
| 104 | + | for i, k := range [...]string{"Id", "IconName", "Status"} { | |||
| 105 | + | e.Struct() | |||
| 106 | + | e.Str(k) | |||
| 107 | + | e.Variant("s") | |||
| 108 | + | e.Str(f.cur[i]) | |||
| 109 | + | } | |||
| 110 | + | e.Struct() | |||
| 111 | + | e.Str("IconPixmap") | |||
| 112 | + | e.Variant("a(iiay)") | |||
| 113 | + | px := e.BeginArray('(') | |||
| 114 | + | e.Struct() | |||
| 115 | + | e.Int32(1) | |||
| 116 | + | e.Int32(1) | |||
| 117 | + | e.ByteArray(f.pixel[:]) | |||
| 118 | + | e.EndArray(px) | |||
| 119 | + | e.EndArray(a) | |||
| 120 | + | if _, err := f.c.Send(); err != nil { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // register registers the item with the watcher, by bus name. | |||
| 127 | + | func (f *fakeItem) register(t *testing.T) { | |||
| 128 | + | f.run(t, func() { | |||
| 129 | + | f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name) | |||
| 130 | + | _, _ = f.c.Send() | |||
| 131 | + | }) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // setStatus changes the item's status and emits NewStatus. | |||
| 135 | + | func (f *fakeItem) setStatus(t *testing.T, status string) { | |||
| 136 | + | f.run(t, func() { | |||
| 137 | + | f.cur[2] = status | |||
| 138 | + | f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status) | |||
| 139 | + | _, _ = f.c.Send() | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // quit closes the item's connection, as when the application exits. | |||
| 144 | + | func (f *fakeItem) quit(t *testing.T) { | |||
| 145 | + | f.run(t, func() { _ = f.c.Close() }) | |||
| 146 | + | <-f.closed | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // lines reads the tray's output. | |||
| 150 | + | type lines struct { | |||
| 151 | + | ch chan string | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func readLines(r io.Reader) *lines { | |||
| 155 | + | l := &lines{ch: make(chan string, 64)} | |||
| 156 | + | go func() { | |||
| 157 | + | sc := bufio.NewScanner(r) | |||
| 158 | + | for sc.Scan() { | |||
| 159 | + | l.ch <- sc.Text() | |||
| 160 | + | } | |||
| 161 | + | close(l.ch) | |||
| 162 | + | }() | |||
| 163 | + | return l | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // waitFor returns the first line for which ok is true. | |||
| 167 | + | func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string { | |||
| 168 | + | t.Helper() | |||
| 169 | + | timeout := time.After(5 * time.Second) | |||
| 170 | + | for { | |||
| 171 | + | select { | |||
| 172 | + | case s, open := <-l.ch: | |||
| 173 | + | if !open { | |||
| 174 | + | t.Fatalf("output ended while waiting for %s", what) | |||
| 175 | + | } | |||
| 176 | + | if ok(s) { | |||
| 177 | + | return s | |||
| 178 | + | } | |||
| 179 | + | case <-timeout: | |||
| 180 | + | t.Fatalf("timed out waiting for %s", what) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // startTray runs a tray on the bus. Cancel it and receive from the channel | |||
| 186 | + | // to stop it; the test cleanup does both when the test did not. | |||
| 187 | + | func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) { | |||
| 188 | + | pr, pw := io.Pipe() | |||
| 189 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 190 | + | done := make(chan error, 1) | |||
| 191 | + | var result error | |||
| 192 | + | stopped := make(chan struct{}) | |||
| 193 | + | opt := Options{ | |||
| 194 | + | Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), | |||
| 195 | + | Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250}, | |||
| 196 | + | Out: pw, | |||
| 197 | + | } | |||
| 198 | + | c := dial(t, addr) | |||
| 199 | + | go func() { | |||
| 200 | + | result = Run(ctx, c, opt) | |||
| 201 | + | _ = pw.Close() | |||
| 202 | + | close(stopped) | |||
| 203 | + | done <- result | |||
| 204 | + | }() | |||
| 205 | + | t.Cleanup(func() { | |||
| 206 | + | cancel() | |||
| 207 | + | <-stopped | |||
| 208 | + | }) | |||
| 209 | + | return readLines(pr), cancel, done | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestTrayAsWatcher(t *testing.T) { | |||
| 213 | + | addr := privateBus(t) | |||
| 214 | + | out, cancel, done := startTray(t, addr) | |||
| 215 | + | if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" { | |||
| 216 | + | t.Fatalf("first line %q, want empty", s) | |||
| 217 | + | } | |||
| 218 | + | // Wait until the tray owns the watcher name. | |||
| 219 | + | probe := dial(t, addr) | |||
| 220 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 221 | + | for { | |||
| 222 | + | probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName) | |||
| 223 | + | m, err := probe.Call() | |||
| 224 | + | if err != nil { | |||
| 225 | + | t.Fatal(err) | |||
| 226 | + | } | |||
| 227 | + | if m.Body().Bool() { | |||
| 228 | + | break | |||
| 229 | + | } | |||
| 230 | + | if time.Now().After(deadline) { | |||
| 231 | + | t.Fatal("the tray did not become the watcher") | |||
| 232 | + | } | |||
| 233 | + | time.Sleep(10 * time.Millisecond) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |||
| 237 | + | steam.register(t) | |||
| 238 | + | line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") }) | |||
| 239 | + | if !strings.Contains(line, "38;2;") { | |||
| 240 | + | t.Fatalf("the icon is not colored: %q", line) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | steam.setStatus(t, "NeedsAttention") | |||
| 244 | + | out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") }) | |||
| 245 | + | ||||
| 246 | + | steam.setStatus(t, "Passive") | |||
| 247 | + | out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" }) | |||
| 248 | + | ||||
| 249 | + | steam.setStatus(t, "Active") | |||
| 250 | + | out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") }) | |||
| 251 | + | ||||
| 252 | + | // The application quits: its item goes away. | |||
| 253 | + | steam.quit(t) | |||
| 254 | + | out.waitFor(t, "the item to go away", func(s string) bool { return s == "" }) | |||
| 255 | + | ||||
| 256 | + | cancel() | |||
| 257 | + | if err := <-done; err != nil { | |||
| 258 | + | t.Fatalf("Run: %v", err) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 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 { | |||
| 298 | + | t.Fatalf("RequestName = %d, %v", code, err) | |||
| 299 | + | } | |||
| 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 { | |||
| 318 | + | return | |||
| 319 | + | } | |||
| 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) | |||
| 328 | + | } | |||
| 329 | + | e.EndArray(a) | |||
| 330 | + | default: | |||
| 331 | + | w.c.NewReply(m, "") | |||
| 332 | + | } | |||
| 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) | |||
| 366 | + | ||||
| 367 | + | out, _, _ := startTray(t, addr) | |||
| 368 | + | out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") }) | |||
| 369 | + | ||||
| 370 | + | // A second item is announced by the other watcher, by bus name only. | |||
| 371 | + | steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |||
| 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 { | |||
| 422 | + | t.Fatal(err) | |||
| 423 | + | } | |||
| 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 | + | for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} { | |||
| 462 | + | probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad) | |||
| 463 | + | if _, err := probe.Call(); err == nil { | |||
| 464 | + | t.Fatalf("%q was accepted", bad) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath { | |||
| 468 | + | t.Fatalf("registered items %q", items) | |||
| 469 | + | } | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | // TestRefreshNoAllocs checks the steady state: an item signals a change, the | |||
| 473 | + | // tray asks for its properties and reads the reply. Nothing allocates once | |||
| 474 | + | // the buffers have grown. | |||
| 475 | + | func TestRefreshNoAllocs(t *testing.T) { | |||
| 476 | + | addr := privateBus(t) | |||
| 477 | + | app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200}) | |||
| 478 | + | var out strings.Builder | |||
| 479 | + | tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out}, | |||
| 480 | + | frame: make([]byte, 0, 256), last: make([]byte, 0, 256)} | |||
| 481 | + | tr.opt.Log = slog.New(slog.DiscardHandler) | |||
| 482 | + | tr.add(app.name, "") | |||
| 483 | + | cycle := func() { | |||
| 484 | + | tr.refresh(tr.items[0]) | |||
| 485 | + | for len(tr.pending) > 0 { | |||
| 486 | + | m, err := tr.c.ReadMessage() | |||
| 487 | + | if err != nil { | |||
| 488 | + | t.Fatal(err) | |||
| 489 | + | } | |||
| 490 | + | tr.dispatch(m) | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 493 | + | for range 3 { | |||
| 494 | + | cycle() // resolve the icon and grow the buffers | |||
| 495 | + | } | |||
| 496 | + | if tr.err != nil || !tr.items[0].loaded { | |||
| 497 | + | t.Fatalf("item not loaded: %v", tr.err) | |||
| 498 | + | } | |||
| 499 | + | if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 { | |||
| 500 | + | t.Fatalf("%v allocations per refresh, want 0", allocs) | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestRenderSkipsUnchangedFrames(t *testing.T) { | |||
| 505 | + | var buf strings.Builder | |||
| 506 | + | tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}} | |||
| 507 | + | tr.items = []*item{{loaded: true, icon: "X"}} | |||
| 508 | + | for range 3 { | |||
| 509 | + | if err := tr.render(false); err != nil { | |||
| 510 | + | t.Fatal(err) | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 513 | + | if got := strings.Count(buf.String(), "\n"); got != 1 { | |||
| 514 | + | t.Fatalf("%d frames written, want 1", got) | |||
| 515 | + | } | |||
| 516 | + | if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 { | |||
| 517 | + | t.Fatalf("render allocates %v times", allocs) | |||
| 518 | + | } | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func TestBetterPixmapSize(t *testing.T) { | |||
| 522 | + | // The largest icon up to 64x64 wins; above that, the smallest. | |||
| 523 | + | for _, c := range []struct { | |||
| 524 | + | sizes []int // sides, in the order the item lists them | |||
| 525 | + | want int | |||
| 526 | + | }{ | |||
| 527 | + | {[]int{16, 32, 128}, 32}, | |||
| 528 | + | {[]int{128, 32, 16}, 32}, | |||
| 529 | + | {[]int{256, 128}, 128}, | |||
| 530 | + | {[]int{22}, 22}, | |||
| 531 | + | } { | |||
| 532 | + | best := 0 | |||
| 533 | + | for _, s := range c.sizes { | |||
| 534 | + | if best == 0 || better(s*s, best*best) { | |||
| 535 | + | best = s | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | if best != c.want { | |||
| 539 | + | t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want) | |||
| 540 | + | } | |||
| 541 | + | } | |||
| 542 | + | } | |||