1package tray
2
3import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "strings"
8 "time"
9 "unicode"
10)
11
12// Resolver picks the icon of a tray item from what the system already knows,
13// so a new application needs no table entry:
14//
15// 1. the user's [tray.icons] override;
16// 2. a glyph for a freedesktop status icon name, such as "audio-volume-muted";
17// 3. a Nerd Font glyph named after the application, such as "fa-steam";
18// 4. a glyph for the category of its .desktop file, such as Game;
19// 5. a letter in a circle.
20type Resolver struct {
21 lookup func(name string) (rune, bool)
22 icons map[string]string // lowercased keys
23 dataDirs []string
24 apps map[string]*desktopApp // nil until the first lookup
25 mtimes []time.Time // of each applications dir when apps was read
26}
27
28// desktopApp is what the resolver uses from a .desktop file.
29type desktopApp struct {
30 name string
31 words []string // file name, Icon and StartupWMClass, lowercased
32 categories []string
33 hidden bool // Hidden=true: the application counts as deleted
34}
35
36// NewResolver returns a resolver that finds glyphs with lookup (a font's
37// glyph names) and .desktop files under dataDirs/applications. Nil dataDirs
38// uses the XDG data directories.
39func NewResolver(lookup func(string) (rune, bool), icons map[string]string, dataDirs []string) *Resolver {
40 r := &Resolver{lookup: lookup, icons: make(map[string]string, len(icons)), dataDirs: dataDirs}
41 for k, v := range icons {
42 r.icons[strings.ToLower(k)] = v
43 }
44 if r.dataDirs == nil {
45 r.dataDirs = xdgDataDirs()
46 }
47 return r
48}
49
50// prefixes are the Nerd Font sets, brand-rich ones first.
51var prefixes = [...]string{"linux", "dev", "fa", "md", "seti", "fae", "cod", "oct", "custom"}
52
53// stopwords are parts of item ids and names that say nothing about the
54// application.
55var stopwords = map[string]bool{
56 "org": true, "com": true, "net": true, "io": true, "dev": true, "github": true, "gitlab": true,
57 "desktop": true, "app": true, "application": true, "tray": true, "icon": true, "status": true,
58 "notifier": true, "item": true, "indicator": true, "applet": true, "client": true, "linux": true,
59 "gnome": true, "kde": true, "freedesktop": true, "symbolic": true, "panel": true, "the": true,
60 "ayatana": true, "notification": true, "electron": true, "bin": true,
61}
62
63// statusIcons maps freedesktop status icon names, by prefix, to glyphs, most
64// specific first. A prefix matches whole dash-separated parts, so
65// "signal-desktop" or "evolution-mail" are not status icons.
66var statusIcons = [...][2]string{
67 {"audio-volume-muted", "md-volume_off"}, {"audio-volume", "md-volume_high"},
68 {"microphone-sensitivity-muted", "md-microphone_off"}, {"microphone-sensitivity", "md-microphone"},
69 {"audio-input-microphone-muted", "md-microphone_off"}, {"audio-input-microphone", "md-microphone"},
70 {"bluetooth", "md-bluetooth"}, {"battery", "md-battery"},
71 {"network-vpn", "md-vpn"}, {"nm-vpn", "md-vpn"}, {"network-wired", "md-lan"}, {"nm-device-wired", "md-lan"},
72 {"network-wireless", "md-wifi"}, {"nm-signal", "md-wifi"}, {"nm-device-wireless", "md-wifi"},
73 {"network", "md-lan"},
74 {"software-update", "md-update"}, {"system-software-update", "md-update"},
75 {"mail-unread", "md-email"}, {"mail-read", "md-email"}, {"mail-message-new", "md-email"},
76}
77
78// statusIcon returns the glyph name for a status icon name, or "".
79func statusIcon(iconName string) string {
80 n := strings.ToLower(iconName)
81 for _, s := range statusIcons {
82 if rest, ok := strings.CutPrefix(n, s[0]); ok && (rest == "" || rest[0] == '-') {
83 return s[1]
84 }
85 }
86 return ""
87}
88
89// categories maps .desktop categories to glyphs, most specific first: an
90// application lists broad categories too, such as "Network;WebBrowser".
91var categories = [...][2]string{
92 {"TerminalEmulator", "md-console"}, {"WebBrowser", "md-web"}, {"Email", "md-email"},
93 {"InstantMessaging", "md-chat"}, {"Chat", "md-chat"}, {"Game", "md-gamepad_variant"},
94 {"Music", "md-music"}, {"Audio", "md-music"}, {"Player", "md-play_circle"},
95 {"Video", "md-play_circle"}, {"AudioVideo", "md-play_circle"}, {"Graphics", "md-palette"},
96 {"IDE", "md-code_braces"}, {"Development", "md-code_braces"}, {"Office", "md-file_document"},
97 {"FileManager", "md-folder"}, {"FileTransfer", "md-download"}, {"P2P", "md-download"},
98 {"Security", "md-shield"}, {"Network", "md-lan"}, {"Settings", "md-cog"},
99 {"System", "md-cog"}, {"Utility", "md-tools"},
100}
101
102// Resolve returns the icon text of an item: one glyph, or the user's
103// override. id, iconName and title are the item's properties; any may be
104// empty. A title can say anything ("Sync complete"), so it only counts as a
105// whole name, after the stronger signals.
106func (r *Resolver) Resolve(id, iconName, title string) string {
107 if strings.HasPrefix(iconName, "/") {
108 iconName = "" // a file path names no application
109 }
110 keys := [...]string{id, iconName, title}
111 for _, k := range keys {
112 if v, ok := r.icons[strings.ToLower(k)]; ok && k != "" {
113 return r.glyphOr(v, v)
114 }
115 }
116 app := r.app(id, iconName, title)
117 if app != nil {
118 if v, ok := r.icons[strings.ToLower(app.name)]; ok {
119 return r.glyphOr(v, v)
120 }
121 }
122 // Status icon names first: "audio-volume-muted" is a state, not a brand.
123 if s := statusIcon(iconName); s != "" {
124 if g, ok := r.glyph(s); ok {
125 return g
126 }
127 }
128 names := []string{id, iconName}
129 if app != nil {
130 names = append(names, app.words...)
131 names = append(names, app.name)
132 }
133 for _, n := range names {
134 if g, ok := r.brand(n); ok {
135 return g
136 }
137 }
138 if g, ok := r.prefixed(underscored(title)); ok {
139 return g
140 }
141 if app != nil {
142 for _, c := range categories {
143 for _, ac := range app.categories {
144 if ac == c[0] {
145 if g, ok := r.glyph(c[1]); ok {
146 return g
147 }
148 }
149 }
150 }
151 }
152 for _, n := range [...]string{nameOf(app), title, id} {
153 if l := firstLetter(n); l != 0 {
154 return r.glyphOr("md-alpha_"+string(l)+"_circle", string(unicode.ToUpper(l)))
155 }
156 }
157 return "•"
158}
159
160func nameOf(app *desktopApp) string {
161 if app == nil {
162 return ""
163 }
164 return app.name
165}
166
167// glyph returns the glyph called name as a string.
168func (r *Resolver) glyph(name string) (string, bool) {
169 if rn, ok := r.lookup(name); ok {
170 return string(rn), true
171 }
172 return "", false
173}
174
175// glyphOr returns the glyph called name, or fallback when the font has none.
176func (r *Resolver) glyphOr(name, fallback string) string {
177 if g, ok := r.glyph(name); ok {
178 return g
179 }
180 return fallback
181}
182
183// underscored lowercases s and joins its parts with '_', the way glyph names
184// are spelled: "Google Chrome" gives "google_chrome".
185func underscored(s string) string {
186 return strings.Map(func(c rune) rune {
187 if c == '-' || c == '.' || c == ' ' {
188 return '_'
189 }
190 return unicode.ToLower(c)
191 }, s)
192}
193
194// brand finds a glyph named after s: its whole name, then each word.
195func (r *Resolver) brand(s string) (string, bool) {
196 if whole := underscored(s); !stopwords[whole] {
197 if g, ok := r.prefixed(whole); ok {
198 return g, true
199 }
200 }
201 for _, w := range words(s) {
202 if g, ok := r.prefixed(w); ok {
203 return g, true
204 }
205 }
206 return "", false
207}
208
209func (r *Resolver) prefixed(w string) (string, bool) {
210 if len(w) < 3 {
211 return "", false
212 }
213 for _, p := range prefixes {
214 if g, ok := r.glyph(p + "-" + w); ok {
215 return g, true
216 }
217 }
218 return "", false
219}
220
221// words splits s into lowercase alphanumeric words of 3 or more letters,
222// without stopwords or numbers.
223func words(s string) []string {
224 var out []string
225 for f := range strings.FieldsFuncSeq(strings.ToLower(s), func(c rune) bool {
226 return !unicode.IsLetter(c) && !unicode.IsDigit(c)
227 }) {
228 if len(f) >= 3 && !stopwords[f] && strings.ContainsFunc(f, unicode.IsLetter) {
229 out = append(out, f)
230 }
231 }
232 return out
233}
234
235func firstLetter(s string) rune {
236 for _, c := range strings.ToLower(s) {
237 if c >= 'a' && c <= 'z' {
238 return c
239 }
240 }
241 return 0
242}
243
244// app finds the .desktop file of an item. The index is read on the first
245// call, and again on a miss when an applications directory changed since,
246// as when an application is installed during the session.
247func (r *Resolver) app(id, iconName, title string) *desktopApp {
248 if r.apps == nil {
249 r.reload()
250 }
251 if a := r.findApp(id, iconName, title); a != nil {
252 return a
253 }
254 if r.changed() {
255 r.reload()
256 return r.findApp(id, iconName, title)
257 }
258 return nil
259}
260
261func (r *Resolver) reload() {
262 r.mtimes = r.dirTimes(r.mtimes[:0])
263 r.apps = loadApps(r.dataDirs)
264}
265
266// dirTimes appends the mtime of each applications directory, zero when it
267// is missing.
268func (r *Resolver) dirTimes(ts []time.Time) []time.Time {
269 for _, d := range r.dataDirs {
270 var t time.Time
271 if st, err := os.Stat(filepath.Join(d, "applications")); d != "" && err == nil {
272 t = st.ModTime()
273 }
274 ts = append(ts, t)
275 }
276 return ts
277}
278
279// findApp looks up the whole id, icon name and title, then the words of the
280// id and icon name. Titles are free text: their words are not used.
281func (r *Resolver) findApp(id, iconName, title string) *desktopApp {
282 for _, k := range [...]string{id, iconName, title} {
283 if a := r.apps[strings.ToLower(k)]; a != nil && k != "" {
284 return a
285 }
286 }
287 for _, k := range [...]string{id, iconName} {
288 for _, w := range words(k) {
289 if a := r.apps[w]; a != nil {
290 return a
291 }
292 }
293 }
294 return nil
295}
296
297// changed reports whether an applications directory changed since the
298// index was read. Comparing mtimes with themselves, not with the clock,
299// is safe from clock skew.
300func (r *Resolver) changed() bool {
301 var buf [8]time.Time
302 for i, t := range r.dirTimes(buf[:0]) {
303 if !t.Equal(r.mtimes[i]) {
304 return true
305 }
306 }
307 return false
308}
309
310func xdgDataDirs() []string {
311 home := os.Getenv("XDG_DATA_HOME")
312 if home == "" {
313 if h, err := os.UserHomeDir(); err == nil {
314 home = filepath.Join(h, ".local", "share")
315 }
316 }
317 dirs := os.Getenv("XDG_DATA_DIRS")
318 if dirs == "" {
319 dirs = "/usr/local/share:/usr/share"
320 }
321 return append([]string{home}, filepath.SplitList(dirs)...)
322}
323
324// loadApps indexes the .desktop files of dataDirs by lowercased file name,
325// Icon and StartupWMClass, and by the last part of reverse-DNS ones
326// ("org.gnome.Settings" gives "settings" too). Whole values win over last
327// parts, and earlier directories over later ones, as in the XDG spec. A
328// hidden application keeps its keys, with no application behind them.
329func loadApps(dataDirs []string) map[string]*desktopApp {
330 apps := map[string]*desktopApp{}
331 var all []*desktopApp
332 for _, d := range dataDirs {
333 if d == "" {
334 continue
335 }
336 files, _ := filepath.Glob(filepath.Join(d, "applications", "*.desktop"))
337 for _, f := range files {
338 if a := readDesktop(f); a != nil {
339 all = append(all, a)
340 }
341 }
342 }
343 add := func(k string, a *desktopApp) {
344 if _, taken := apps[k]; !taken && k != "" {
345 if a.hidden {
346 a = nil
347 }
348 apps[k] = a
349 }
350 }
351 for _, a := range all {
352 for _, w := range a.words {
353 add(w, a)
354 }
355 }
356 for _, a := range all {
357 for _, v := range a.words {
358 if i := strings.LastIndexByte(v, '.'); i >= 0 {
359 add(v[i+1:], a)
360 }
361 }
362 }
363 return apps
364}
365
366// readDesktop reads the keys the resolver needs from the [Desktop Entry]
367// group of a .desktop file.
368func readDesktop(path string) *desktopApp {
369 f, err := os.Open(path)
370 if err != nil {
371 return nil
372 }
373 defer func() { _ = f.Close() }()
374 a := &desktopApp{words: []string{strings.ToLower(strings.TrimSuffix(filepath.Base(path), ".desktop"))}}
375 in := false
376 sc := bufio.NewScanner(f)
377 for sc.Scan() {
378 line := strings.TrimSpace(sc.Text())
379 if strings.HasPrefix(line, "[") {
380 if in {
381 break // only the first group is the entry
382 }
383 in = line == "[Desktop Entry]"
384 continue
385 }
386 k, v, ok := strings.Cut(line, "=")
387 if !in || !ok {
388 continue
389 }
390 switch strings.TrimSpace(k) {
391 case "Name":
392 a.name = strings.TrimSpace(v)
393 case "Icon":
394 if v = strings.TrimSpace(v); v != "" && !strings.HasPrefix(v, "/") {
395 a.words = append(a.words, strings.ToLower(v))
396 }
397 case "StartupWMClass":
398 if v = strings.TrimSpace(v); v != "" {
399 a.words = append(a.words, strings.ToLower(v))
400 }
401 case "Categories":
402 a.categories = strings.FieldsFunc(v, func(c rune) bool { return c == ';' })
403 case "Hidden":
404 a.hidden = strings.TrimSpace(v) == "true"
405 case "Type":
406 if strings.TrimSpace(v) != "Application" {
407 return nil
408 }
409 }
410 }
411 if !in {
412 return nil
413 }
414 return a
415}