Files
mesh-catalog/modules/rofi/cmd/rofi-tools/rofi.go
T
jochen f0f0a79623 rofi: the launcher as a module, claiming node-launcher and serving menu (hq ADR 0208)
Places the seat's dmenu-compatible command, the launcher and power menu, its
themes in the decided faces, and its key bindings as an i3 drop-in; Go tools
menu, applications, themes and run.
2026-10-04 13:15:39 +02:00

411 lines
11 KiB
Go

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
}