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.
411 lines
11 KiB
Go
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
|
|
}
|