package main import ( "bufio" "errors" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" ) // The menu waits for a person, bounded below the runtime's call limit. const ( menuDefault = 20 menuMost = 25 mostItems = 1000 ) // MenuQuestion is what node-launcher.menu asks. type MenuQuestion struct { Items []string Prompt string Message string AllowCustom bool Timeout time.Duration } func menuQuestion(args map[string]any) (MenuQuestion, error) { var q MenuQuestion var err error if q.Items, err = texts(args, "items"); err != nil { return q, err } if len(q.Items) == 0 { return q, errors.New("items is required: at least one choice") } if len(q.Items) > mostItems { return q, fmt.Errorf("%d items; a menu shows at most %d", len(q.Items), mostItems) } for i, item := range q.Items { if strings.ContainsAny(item, "\n\r") { return q, fmt.Errorf("items[%d] spans lines; a choice is one line", i) } } if q.Prompt, err = text(args, "prompt", false); err != nil { return q, err } if q.Prompt == "" { q.Prompt = "Choose" } if q.Message, err = text(args, "message", false); err != nil { return q, err } if q.AllowCustom, err = flag(args, "allow_custom", false); err != nil { return q, err } q.Timeout, err = seconds(args, "timeout_seconds", menuDefault, menuMost) return q, err } // MenuAnswer is what node-launcher.menu answers. type MenuAnswer struct { Chosen string `json:"chosen,omitempty"` Index int `json:"index"` Custom bool `json:"custom,omitempty"` Cancelled bool `json:"cancelled"` TimedOut bool `json:"timed_out,omitempty"` } // Menu shows the list with `rofi -dmenu` in the operator's session and answers the choice. func Menu(q MenuQuestion) (MenuAnswer, error) { s, err := findSession() if err != nil { return MenuAnswer{}, err } args := []string{"-dmenu", "-i", "-p", q.Prompt, "-format", "s"} if q.Message != "" { args = append(args, "-mesg", q.Message) } if !q.AllowCustom { args = append(args, "-no-custom") } r, err := s.run(q.Timeout, strings.Join(q.Items, "\n")+"\n", "rofi", args...) if errors.Is(err, ErrTimedOut) { return MenuAnswer{Index: -1, Cancelled: true, TimedOut: true}, nil } if err != nil { return MenuAnswer{}, err } switch r.Code { case 0: case 1: return MenuAnswer{Index: -1, Cancelled: true}, nil default: return MenuAnswer{}, fmt.Errorf("rofi exited %d: %s", r.Code, strings.TrimSpace(r.Stderr)) } chosen := strings.TrimRight(r.Stdout, "\n") for i, item := range q.Items { if item == chosen { return MenuAnswer{Chosen: chosen, Index: i}, nil } } return MenuAnswer{Chosen: chosen, Index: -1, Custom: true}, nil } // Application is one desktop entry the launcher offers. type Application struct { ID string `json:"id"` Name string `json:"name"` GenericName string `json:"generic_name,omitempty"` Comment string `json:"comment,omitempty"` Exec string `json:"exec"` Terminal bool `json:"terminal,omitempty"` Categories []string `json:"categories,omitempty"` Hidden bool `json:"hidden,omitempty"` File string `json:"file"` keywords string } // ApplicationsResult is what rofi_applications answers. type ApplicationsResult struct { Directories []string `json:"directories"` Count int `json:"count"` Truncated bool `json:"truncated,omitempty"` Applications []Application `json:"applications"` } // applicationDirs are the directories desktop entries are read from, the first winning an id: the // account's own, then XDG_DATA_DIRS (as the session has it), then flatpak's exports. func applicationDirs() []string { home := operatorHome() dirs := []string{filepath.Join(home, ".local", "share", "applications")} data := os.Getenv("XDG_DATA_DIRS") if data == "" { data = "/usr/local/share:/usr/share" } for _, d := range strings.Split(data, ":") { if d != "" { dirs = append(dirs, filepath.Join(d, "applications")) } } dirs = append(dirs, filepath.Join(home, ".local", "share", "flatpak", "exports", "share", "applications"), "/var/lib/flatpak/exports/share/applications") return unique(dirs) } func unique(in []string) []string { seen := map[string]bool{} var out []string for _, s := range in { if !seen[s] { seen[s] = true out = append(out, s) } } return out } // Applications reads every desktop entry in dirs, the first directory winning an id. func Applications(dirs []string, query string, showHidden bool, limit int) ApplicationsResult { byID := map[string]Application{} for _, dir := range dirs { _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || !strings.HasSuffix(path, ".desktop") { return nil } rel, _ := filepath.Rel(dir, path) id := strings.ReplaceAll(rel, string(filepath.Separator), "-") if _, taken := byID[id]; taken { return nil } if app, ok := readDesktopEntry(path); ok { app.ID = id byID[id] = app } return nil }) } q := strings.ToLower(query) out := ApplicationsResult{Directories: dirs, Applications: []Application{}} ids := make([]string, 0, len(byID)) for id := range byID { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { a, b := byID[ids[i]], byID[ids[j]] if strings.ToLower(a.Name) != strings.ToLower(b.Name) { return strings.ToLower(a.Name) < strings.ToLower(b.Name) } return a.ID < b.ID }) for _, id := range ids { app := byID[id] if app.Hidden && !showHidden { continue } if q != "" && !strings.Contains(strings.ToLower(app.Name+"\x00"+app.GenericName+"\x00"+app.keywords+"\x00"+app.Exec), q) { continue } out.Count++ if len(out.Applications) < limit { out.Applications = append(out.Applications, app) } else { out.Truncated = true } } return out } // readDesktopEntry reads the [Desktop Entry] group of an application's file, unlocalised. func readDesktopEntry(path string) (Application, bool) { f, err := os.Open(path) if err != nil { return Application{}, false } defer f.Close() app := Application{File: path} kind := "" in := false scan := bufio.NewScanner(f) for scan.Scan() { line := strings.TrimSpace(scan.Text()) if strings.HasPrefix(line, "[") { in = line == "[Desktop Entry]" continue } if !in || line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } k, v = strings.TrimSpace(k), strings.TrimSpace(v) switch k { case "Type": kind = v case "Name": app.Name = v case "GenericName": app.GenericName = v case "Comment": app.Comment = v case "Exec": app.Exec = v case "Keywords": app.keywords = v case "Terminal": app.Terminal = v == "true" case "Categories": for _, c := range strings.Split(v, ";") { if c != "" { app.Categories = append(app.Categories, c) } } case "NoDisplay", "Hidden": app.Hidden = app.Hidden || v == "true" } } if kind != "Application" || app.Name == "" { return Application{}, false } return app, true } // Theme is one launcher theme. type Theme struct { Name string `json:"name"` File string `json:"file"` Source string `json:"source"` } // ThemesResult is what rofi_themes answers. type ThemesResult struct { Current string `json:"current"` Themes []Theme `json:"themes"` } type themeDir struct{ path, source string } func configFile() string { return filepath.Join(operatorHome(), ".config", "rofi", "config.rasi") } func themeDirs() []themeDir { home := operatorHome() return []themeDir{ {filepath.Join(home, ".config", "rofi", "themes"), "the account's (the mesh places mesh and mesh-powermenu here)"}, {filepath.Join(home, ".local", "share", "rofi", "themes"), "the account's"}, {"/usr/share/rofi/themes", "the distribution's"}, } } var themeLine = regexp.MustCompile(`(?m)^\s*@theme\s+"([^"]+)"`) // Themes lists every .rasi theme, the first directory winning a name, and the configured one. func Themes(dirs []themeDir, config string) ThemesResult { out := ThemesResult{Themes: []Theme{}} if raw, err := os.ReadFile(config); err == nil { if m := themeLine.FindSubmatch(raw); m != nil { out.Current = string(m[1]) } } seen := map[string]bool{} for _, d := range dirs { entries, err := os.ReadDir(d.path) if err != nil { continue } for _, e := range entries { name, ok := strings.CutSuffix(e.Name(), ".rasi") if !ok || e.IsDir() || seen[name] { continue } seen[name] = true out.Themes = append(out.Themes, Theme{Name: name, File: filepath.Join(d.path, e.Name()), Source: d.source}) } } sort.Slice(out.Themes, func(i, j int) bool { return out.Themes[i].Name < out.Themes[j].Name }) return out } // RunResult is what rofi_run answers. type RunResult struct { Unit string `json:"unit"` Argv []string `json:"argv"` } // Run starts an application or a command in the session under the account's service manager. func Run(application string, command []string) (RunResult, error) { if (application == "") == (len(command) == 0) { return RunResult{}, errors.New("name an application or give a command, one of the two") } argv := command if application != "" { var app *Application for _, a := range Applications(applicationDirs(), "", true, 1<<20).Applications { if a.ID == application { a := a app = &a break } } if app == nil { return RunResult{}, fmt.Errorf("no desktop entry %s on this machine", application) } var err error if argv, err = execWords(app.Exec); err != nil { return RunResult{}, fmt.Errorf("%s: %w", application, err) } if app.Terminal { argv = append([]string{"rofi-sensible-terminal", "-e"}, argv...) } } s, err := findSession() if err != nil { return RunResult{}, err } unit := uniqueUnit("rofi-run") if err := s.detach(unit, argv...); err != nil { return RunResult{}, err } return RunResult{Unit: unit + ".service", Argv: argv}, nil } // execWords splits a desktop entry's Exec into words (its quoting rules), dropping the field codes a // launcher fills with files or URLs, since none are given. func execWords(exec string) ([]string, error) { var words []string var cur strings.Builder inWord, quoted := false, false for i := 0; i < len(exec); i++ { c := exec[i] switch { case quoted && c == '\\' && i+1 < len(exec): i++ cur.WriteByte(exec[i]) case c == '"': quoted = !quoted inWord = true case !quoted && (c == ' ' || c == '\t'): if inWord { words = append(words, cur.String()) cur.Reset() inWord = false } default: cur.WriteByte(c) inWord = true } } if quoted { return nil, fmt.Errorf("unbalanced quote in Exec %q", exec) } if inWord { words = append(words, cur.String()) } var out []string for _, w := range words { if len(w) == 2 && w[0] == '%' { continue // %f %F %u %U %i %c %k %d %D %n %N %v %m } out = append(out, strings.ReplaceAll(w, "%%", "%")) } if len(out) == 0 { return nil, fmt.Errorf("Exec %q names no program", exec) } return out, nil }