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