diff --git a/modules/rofi/README.md b/modules/rofi/README.md new file mode 100644 index 0000000..827c8e1 --- /dev/null +++ b/modules/rofi/README.md @@ -0,0 +1,88 @@ +# rofi + +The launcher as a module (novox/hq ADR 0208, research 026/04 and 05). + +- Installs `rofi`, claims the mesh's `node-launcher` seat and serves its verb `menu`. Requires + `x11-display` on its own machine. +- **The seat's dmenu-compatible command.** It places `~/.local/bin/dmenu`: choices on standard input, + the chosen one on standard output, exit 1 when nothing was chosen. It runs `rofi -dmenu`, passing + on dmenu's `-p`, `-l` and `-i` and dropping its look options. A script, a notifier or a clipboard + manager calls `dmenu` and works whichever module holds the seat. On a node where the `dmenu` module + holds `node-launcher` instead, the real dmenu answers the same calls. +- Owns `~/.config/rofi/config.rasi` and the themes `mesh` and `mesh-powermenu` in + `~/.config/rofi/themes/`. The faces are JetBrains Mono Nerd Font for the list and the input, and + Inter for messages. Both are packages of the `fonts` module. +- Places its key bindings as its own i3 drop-in, `~/.config/i3/config.d/50-rofi.conf`. The `i3` + module's configuration includes that directory after it sets `$mod`. +- Starts nothing. rofi runs when a key is pressed. + +| key | runs | +|---|---| +| `$mod+d` | applications (`rofi-launch drun`) | +| `$mod+t` | a command (`rofi-launch run`) | +| `$mod+Shift+t` | a command with sudo, in the terminal (`rofi-launch sudo`) | +| `$mod+Shift+w` | the window switcher (`rofi-launch window`) | +| `$mod+Escape` | the power menu (`rofi-powermenu`) | + +## The power menu is here + +The power menu belongs to the session. It is still the launcher's, because all of it is rofi: its +theme, its buttons and its confirmation. Every action it takes is a verb of logind or the service +manager, so it names no window manager and no locker: + +- **lock** is `loginctl lock-session`, which the holder of `node-lock-screen` answers; +- **log out** is `loginctl terminate-session`; +- **suspend, reboot and shut down** are `systemctl`'s. + +A Wayland session gets the same menu from whichever launcher holds the seat there. + +## Tools + +| tool | does | +|---|---| +| `node-launcher.menu` | show a list in the operator's session and answer the chosen line and its index, or `cancelled` (also when nobody answers within the timeout, 20 s by default, 25 s at most) | +| `rofi_applications` | the desktop entries the launcher offers, the account's own winning an id; filter by words, hidden ones on request | +| `rofi_themes` | every theme (the mesh's, the account's, the distribution's) and the one configured | +| `rofi_run` | start a desktop entry or a command in the session, under the account's service manager | + +## What it improves on what was found + +- **Plain `dmenu` calls work again.** The one found on 2026-10-04 is the notifier's context menu: on + both workstations it called `/usr/bin/dmenu`, which neither had installed. The `dunst` module now + calls `dmenu`, and this module answers it. The operator's own scripts all call `rofi -dmenu`, which + keeps working. +- **The theme is one file per face.** There is no colour file imported from a cloned theme + repository. The faces are the decided ones (research 026/04): Iosevka and Hack are gone. +- **The retry after resume no longer reopens a closed menu.** The found launchers retried + `rofi -dmenu` on any failure, so pressing Escape in the sudo prompt reopened it four times. + `rofi-launch` retries only a failure within half a second, which is a failed keyboard grab. +- **The power menu locks through logind,** so it goes through the one locker. The found one ran + `i3lock` directly and bypassed it. Log out no longer names four window managers. +- `icon-theme` and `window-command` are gone. They named an icon theme and a program (`wmctrl`) that + nothing installs. rofi's defaults serve. + +## What it leaves as found + +- `~/.config/rofi/themes-repo/` (a cloned theme repository), the five symbolic links into it + (`applets`, `images`, `launchers`, `powermenu`, `scripts`), `colors/`, `theme.rasi` and + `powermenu.rasi`. +- The predecessor's scripts in `~/scripts`: `rofi-launcher-normal`, `rofi-launcher-terminal`, + `rofi-launcher-terminal-sudo` and `powermenu` (replaced here), and `theme-picker` with its modes + (settings, once issue 168 closes). + +## Migration (ADR 0182) + +Once the `i3` module carries the main i3 configuration: + +1. Delete `~/.config/rofi/theme.rasi`, `powermenu.rasi`, `colors/`, the five links and `themes-repo/`. + Nothing reads them any more. +2. Delete the four scripts above from `~/scripts`. +3. Your scripts that call `rofi -dmenu` keep working. Calling `dmenu` instead lets them follow the + seat, for example to a Wayland launcher later. + +## Blockers + +- `node-launcher`, `x11-display` and the `i3` drop-in directory are ADR 0208's and the `i3` + module's. Until the controller knows the seat, `mctl` reads the claim as unknown. +- `~/.local/bin` is on `PATH` through the `zsh` module's environment contribution (ADR 0203). The + key bindings name `~/.local/bin/…` in full, so they do not depend on it. Callers of `dmenu` do. diff --git a/modules/rofi/cmd/rofi-tools/args.go b/modules/rofi/cmd/rofi-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/args.go @@ -0,0 +1,97 @@ +// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. +// The same in every desktop module that carries it. +package main + +import ( + "fmt" + "math" + "strings" + "time" +) + +// text is a string argument, trimmed; required says an empty one is refused. +func text(args map[string]any, key string, required bool) (string, error) { + v, present := args[key] + if !present || v == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string, not %T", key, v) + } + s = strings.TrimSpace(s) + if s == "" && required { + return "", fmt.Errorf("%s is required", key) + } + return s, nil +} + +// whole is a whole-number argument within [least, most], or def when absent. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s is a number, not %T", key, v) + } + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("%s is a whole number, not %v", key, f) + } + n := int(f) + if n < least || n > most { + return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) + } + return n, nil +} + +// flag is a boolean argument, or def when absent. +func flag(args map[string]any, key string, def bool) (bool, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false, not %T", key, v) + } + return b, nil +} + +// texts is a list-of-strings argument. +func texts(args map[string]any, key string) ([]string, error) { + v, present := args[key] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + if ss, isStrings := v.([]string); isStrings { + return ss, nil + } + return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) + } + out := make([]string, 0, len(list)) + for i, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) + } + out = append(out, s) + } + return out, nil +} + +// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. +func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { + n, err := whole(args, key, def, 1, most) + return time.Duration(n) * time.Second, err +} diff --git a/modules/rofi/cmd/rofi-tools/main.go b/modules/rofi/cmd/rofi-tools/main.go new file mode 100644 index 0000000..fd7dbb8 --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/main.go @@ -0,0 +1,100 @@ +// rofi's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of +// node-launcher's verb `menu` — the dmenu-compatible command as a tool — and its own tools, served by +// the node's runtime as the operator account. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "node-launcher.menu", + Description: fmt.Sprintf("Show a list in the operator's session and answer the line they choose: "+ + "the launcher's dmenu-compatible command as a tool. Answers chosen and its index, or cancelled "+ + "when they closed it or did not answer within timeout_seconds (default %d, at most %d).", menuDefault, menuMost), + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the choices, one line each"}, + "prompt": map[string]any{"type": "string", "description": "the prompt (default: Choose)"}, + "message": map[string]any{"type": "string", "description": "a line of explanation above the list"}, + "allow_custom": map[string]any{"type": "boolean", "description": "accept a typed answer that is not in the list (default false)"}, + "timeout_seconds": map[string]any{"type": "integer", "description": fmt.Sprintf("give up after this long (default %d, at most %d)", menuDefault, menuMost)}, + }, + "required": []string{"items"}, + }, + Run: func(args map[string]any) (any, error) { + q, err := menuQuestion(args) + if err != nil { + return nil, err + } + return Menu(q) + }, + }, + { + Name: "rofi_applications", + Description: "The desktop applications the launcher offers on this machine, from every desktop entry " + + "directory in the order the launcher reads them (the account's first): id, name, what it runs, " + + "categories and the file. Hidden entries are left out unless asked for.", + Input: map[string]any{ + "query": map[string]any{"type": "string", "description": "only entries whose name, generic name, keywords or command contain this (any case)"}, + "show_hidden": map[string]any{"type": "boolean", "description": "include entries marked NoDisplay or Hidden (default false)"}, + "limit": map[string]any{"type": "integer", "description": "at most this many (default 200, at most 2000)"}, + }, + Run: func(args map[string]any) (any, error) { + query, err := text(args, "query", false) + if err != nil { + return nil, err + } + hidden, err := flag(args, "show_hidden", false) + if err != nil { + return nil, err + } + limit, err := whole(args, "limit", 200, 1, 2000) + if err != nil { + return nil, err + } + return Applications(applicationDirs(), query, hidden, limit), nil + }, + }, + { + Name: "rofi_themes", + Description: "The launcher's themes on this machine — the mesh's, the operator's own and the " + + "distribution's — each with its file, and which one the configuration uses.", + Run: func(map[string]any) (any, error) { return Themes(themeDirs(), configFile()), nil }, + }, + { + Name: "rofi_run", + Description: "Start a program in the operator's session, as the launcher would: a desktop entry by " + + "its id (firefox.desktop), or a command as its words. It runs under the account's own service " + + "manager, so it outlives this call; answers the unit it runs as and what it ran.", + Input: map[string]any{ + "application": map[string]any{"type": "string", "description": "a desktop entry id, as rofi_applications answers it"}, + "command": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "a program and its arguments, one word each"}, + }, + Run: func(args map[string]any) (any, error) { + app, err := text(args, "application", false) + if err != nil { + return nil, err + } + command, err := texts(args, "command") + if err != nil { + return nil, err + } + return Run(app, command) + }, + }, + } +} diff --git a/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in +// every desktop module that carries it. + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +type claim struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + +type environment struct { + Variables map[string]string `json:"variables"` + Path []map[string]any `json:"path"` +} + +type shellCode struct { + For string `json:"for"` + Slot string `json:"slot"` + Code string `json:"code"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(t *testing.T, id string) map[string]any { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %q", id) + return nil +} + +func (m manifest) packages() (present, absent []string) { + for _, r := range m.Resources { + if r["type"] == "package" { + if r["absent"] == true { + absent = append(absent, r["package"].(string)) + } else { + present = append(present, r["package"].(string)) + } + } + } + return present, absent +} + +// sameAsSource checks that a file resource's content is byte for byte the module's source file, so +// the readable file in the repository is what the machine gets. +func (m manifest) sameAsSource(t *testing.T, id, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + r := m.resource(t, id) + if r["type"] != "file" { + t.Fatalf("%s is a %v, not a file", id, r["type"]) + } + if got, _ := r["content"].(string); got != string(want) { + t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + } + if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { + t.Fatalf("%s under the home is the account's", id) + } +} + +// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle +// serves each seat verb the claims promise as ., and that the Go bundle is declared. +func checkTheToolsAgree(t *testing.T, m manifest) { + t.Helper() + own, seat := map[string]bool{}, map[string]bool{} + for _, tool := range tools() { + if strings.Contains(tool.Name, ".") { + seat[tool.Name] = true + } else { + own[tool.Name] = true + } + if strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s has no description", tool.Name) + } + } + listed := map[string]bool{} + for _, name := range m.Tools { + listed[name] = true + if !own[name] { + t.Errorf("module.json lists %s, which the bundle does not serve", name) + } + } + for name := range own { + if !listed[name] { + t.Errorf("the bundle serves %s, which module.json does not list", name) + } + if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") { + t.Errorf("%s is not prefixed with the module's name", name) + } + } + promised := map[string]bool{} + for _, c := range m.Claims { + for _, verb := range c.Serves { + promised[c.Name+"."+verb] = true + if !seat[c.Name+"."+verb] { + t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb) + } + } + } + for name := range seat { + if !promised[name] { + t.Errorf("the bundle serves %s, which no claim promises", name) + } + } + var bundle map[string]any + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + bundle = a + } + } + if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" || + bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" { + t.Errorf("the Go tools bundle: %v", bundle) + } +} + +// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry. +func checkNoSecretsOrInstallationNames(t *testing.T) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + s := strings.ToLower(string(raw)) + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} { + if strings.Contains(s, never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/rofi/cmd/rofi-tools/manifest_test.go b/modules/rofi/cmd/rofi-tools/manifest_test.go new file mode 100644 index 0000000..a9c8265 --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/manifest_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// rofi's shape (novox/hq ADR 0208, research 026/04): it claims node-launcher and serves its verb +// `menu`, requires the X display on its own machine, owns its configuration and its two themes, and +// places the seat's dmenu-compatible command, its launcher and power menu, and its key bindings as +// its own i3 drop-in. + +func TestItClaimsTheLauncherSeatServingMenuAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "rofi" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-launcher", Scope: "node", Serves: []string{"menu"}}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("requires: %v", m.Requires) + } + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"rofi"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { + m := readManifest(t) + for id, source := range map[string]string{ + "configuration": "files/config.rasi", + "theme": "files/themes/mesh.rasi", + "theme-powermenu": "files/themes/mesh-powermenu.rasi", + "dmenu": "files/bin/dmenu", + "launch": "files/bin/rofi-launch", + "powermenu": "files/bin/rofi-powermenu", + "i3-bindings": "files/i3/50-rofi.conf", + } { + m.sameAsSource(t, id, source) + } + for _, id := range []string{"dmenu", "launch", "powermenu"} { + if m.resource(t, id)["mode"] != "0755" { + t.Errorf("%s is executable", id) + } + } + if p := m.resource(t, "dmenu")["path"]; p != "${machine:account-home}/.local/bin/dmenu" { + t.Fatalf("the seat's command is dmenu on the account's PATH: %v", p) + } +} + +func TestTheThemesAreTheMeshsFacesAndTheConfigurationNamesOne(t *testing.T) { + m := readManifest(t) + conf := m.resource(t, "configuration")["content"].(string) + if !strings.Contains(conf, `@theme "mesh"`) || strings.Contains(conf, "@import") { + t.Fatal("the configuration names the theme mesh and imports nothing") + } + for _, id := range []string{"theme", "theme-powermenu"} { + c := m.resource(t, id)["content"].(string) + if !strings.Contains(c, `"JetBrainsMono Nerd Font`) || strings.Contains(c, "@import") || + strings.Contains(c, "Hack") || strings.Contains(c, "Iosevka") { + t.Errorf("%s: the monospace face, and no imported file", id) + } + } + if !strings.Contains(m.resource(t, "theme")["content"].(string), `"Inter 11"`) { + t.Error("the theme's prose is in the interface face") + } +} + +func TestThePowerMenuNamesNoWindowManagerAndNoLocker(t *testing.T) { + m := readManifest(t) + menu := m.resource(t, "powermenu")["content"].(string) + for _, never := range []string{"i3-msg", "i3lock", "betterlockscreen", "openbox", "bspc", "qdbus", "mpc"} { + if strings.Contains(menu, never) { + t.Errorf("the power menu names %s", never) + } + } + for _, want := range []string{"loginctl lock-session", "loginctl terminate-session", "systemctl suspend", "systemctl reboot", "systemctl poweroff"} { + if !strings.Contains(menu, want) { + t.Errorf("the power menu lacks %s", want) + } + } +} + +func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) { + m := readManifest(t) + bindings := m.resource(t, "i3-bindings")["content"].(string) + for _, line := range strings.Split(bindings, "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if !strings.HasPrefix(line, "bindsym $mod+") || !strings.Contains(line, "exec --no-startup-id ~/.local/bin/rofi-") { + t.Errorf("a binding that is not the launcher's: %s", line) + } + } + for _, key := range []string{"$mod+d ", "$mod+Escape "} { + if !strings.Contains(bindings, "bindsym "+key) { + t.Errorf("no %s", key) + } + } + if m.Shell != nil { + t.Fatalf("the launcher starts nothing at session start: %+v", m.Shell) + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/rofi/cmd/rofi-tools/rofi.go b/modules/rofi/cmd/rofi-tools/rofi.go new file mode 100644 index 0000000..810f9de --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/rofi.go @@ -0,0 +1,410 @@ +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 +} diff --git a/modules/rofi/cmd/rofi-tools/rofi_test.go b/modules/rofi/cmd/rofi-tools/rofi_test.go new file mode 100644 index 0000000..73d7d3f --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/rofi_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +const nobody = 4194400 + +func withSession(t *testing.T) string { + t.Helper() + root := fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1", "XDG_SESSION_ID=2") + return root +} + +func TestTheMenuAnswersTheChosenLineAndItsIndex(t *testing.T) { + withSession(t) + bin := fakeBinaries(t, map[string]string{"rofi": `echo "$* DISPLAY=$DISPLAY" > "$LOG"; cat > "$LOG.in"; echo "second"`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + q, err := menuQuestion(map[string]any{"items": []any{"first", "second"}, "prompt": "Pick", "message": "why"}) + if err != nil { + t.Fatal(err) + } + got, err := Menu(q) + if err != nil || got.Chosen != "second" || got.Index != 1 || got.Cancelled || got.Custom { + t.Fatalf("%+v, %v", got, err) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if strings.TrimSpace(string(asked)) != "-dmenu -i -p Pick -format s -mesg why -no-custom DISPLAY=:1" { + t.Fatalf("rofi was asked: %s", asked) + } + in, _ := os.ReadFile(filepath.Join(bin, "log.in")) + if string(in) != "first\nsecond\n" { + t.Fatalf("the choices, one per line: %q", in) + } +} + +func TestClosingTheMenuOrNotAnsweringIsACancelNotAnError(t *testing.T) { + withSession(t) + fakeBinaries(t, map[string]string{"rofi": `exit 1`}) + got, err := Menu(MenuQuestion{Items: []string{"a"}, Prompt: "p", Timeout: 5e9}) + if err != nil || !got.Cancelled || got.Index != -1 { + t.Fatalf("closed: %+v, %v", got, err) + } + fakeBinaries(t, map[string]string{"rofi": `sleep 30`}) + got, err = Menu(MenuQuestion{Items: []string{"a"}, Prompt: "p", Timeout: 2e8}) + if err != nil || !got.Cancelled || !got.TimedOut { + t.Fatalf("not answered: %+v, %v", got, err) + } + fakeBinaries(t, map[string]string{"rofi": `echo typed`}) + got, err = Menu(MenuQuestion{Items: []string{"a"}, Prompt: "p", AllowCustom: true, Timeout: 5e9}) + if err != nil || got.Chosen != "typed" || !got.Custom || got.Index != -1 { + t.Fatalf("typed: %+v, %v", got, err) + } +} + +func TestAMenuQuestionIsBoundedAndOneLinePerChoice(t *testing.T) { + for _, bad := range []map[string]any{ + {}, + {"items": []any{}}, + {"items": []any{"two\nlines"}}, + {"items": []any{"a"}, "timeout_seconds": float64(60)}, + {"items": []any{3.0}}, + } { + if _, err := menuQuestion(bad); err == nil { + t.Errorf("accepted %v", bad) + } + } + q, err := menuQuestion(map[string]any{"items": []any{"a"}}) + if err != nil || q.Prompt != "Choose" || q.Timeout.Seconds() != menuDefault { + t.Fatalf("defaults: %+v, %v", q, err) + } +} + +func TestWithoutASessionTheMenuSaysSo(t *testing.T) { + fakeMachine(t) + if _, err := Menu(MenuQuestion{Items: []string{"a"}, Timeout: 1e9}); !errors.Is(err, ErrNoSession) { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestApplicationsAreTheDesktopEntriesTheAccountsWinning(t *testing.T) { + root := t.TempDir() + own, system := filepath.Join(root, "own"), filepath.Join(root, "system") + writeFile(t, filepath.Join(system, "firefox.desktop"), "[Desktop Entry]\nType=Application\nName=Firefox\nExec=firefox %u\nCategories=Network;WebBrowser;\n[Desktop Action new]\nName=New Window\nExec=firefox --new-window\n") + writeFile(t, filepath.Join(own, "firefox.desktop"), "[Desktop Entry]\nType=Application\nName=Firefox (mine)\nExec=firefox -P work %u\n") + writeFile(t, filepath.Join(system, "kde", "konsole.desktop"), "[Desktop Entry]\nType=Application\nName=Konsole\nExec=konsole\nTerminal=false\nKeywords=terminal;shell;\n") + writeFile(t, filepath.Join(system, "hidden.desktop"), "[Desktop Entry]\nType=Application\nName=Helper\nExec=helper\nNoDisplay=true\n") + writeFile(t, filepath.Join(system, "link.desktop"), "[Desktop Entry]\nType=Link\nName=A link\nURL=https://example.org\n") + got := Applications([]string{own, system}, "", false, 10) + var names []string + for _, a := range got.Applications { + names = append(names, a.ID+"="+a.Name) + } + if !reflect.DeepEqual(names, []string{"firefox.desktop=Firefox (mine)", "kde-konsole.desktop=Konsole"}) { + t.Fatalf("%v", names) + } + if got := Applications([]string{own, system}, "SHELL", false, 10); got.Count != 1 || got.Applications[0].Name != "Konsole" { + t.Fatalf("by keyword: %+v", got) + } + if got := Applications([]string{own, system}, "", true, 1); got.Count != 3 || !got.Truncated || len(got.Applications) != 1 { + t.Fatalf("hidden and limited: %+v", got) + } +} + +func TestThemesAreListedOnceWithTheConfiguredOne(t *testing.T) { + root := t.TempDir() + mine, system := filepath.Join(root, "mine"), filepath.Join(root, "system") + writeFile(t, filepath.Join(mine, "mesh.rasi"), "*{}") + writeFile(t, filepath.Join(system, "mesh.rasi"), "*{}") + writeFile(t, filepath.Join(system, "Arc.rasi"), "*{}") + conf, _ := os.ReadFile(filepath.Join("..", "..", "files", "config.rasi")) + writeFile(t, filepath.Join(root, "config.rasi"), string(conf)) + got := Themes([]themeDir{{mine, "mine"}, {system, "system"}}, filepath.Join(root, "config.rasi")) + if got.Current != "mesh" || len(got.Themes) != 2 || got.Themes[1].Source != "mine" { + t.Fatalf("%+v", got) + } +} + +func TestExecIsSplitAsTheDesktopEntrySpecQuotesIt(t *testing.T) { + for in, want := range map[string][]string{ + "firefox %u": {"firefox"}, + `sh -c "echo \"a b\"" %F`: {"sh", "-c", `echo "a b"`}, + "printf 100%% --flag": {"printf", "100%", "--flag"}, + `"/opt/My App/run" --x`: {"/opt/My App/run", "--x"}, + } { + got, err := execWords(in) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("%s: %q, %v", in, got, err) + } + } + for _, bad := range []string{`"open`, "%u"} { + if _, err := execWords(bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } +} + +func TestRunStartsAnApplicationUnderTheAccountsServiceManager(t *testing.T) { + root := withSession(t) + writeFile(t, filepath.Join(root, "home", ".local", "share", "applications", "htop.desktop"), + "[Desktop Entry]\nType=Application\nName=htop\nExec=htop\nTerminal=true\n") + t.Setenv("XDG_DATA_DIRS", filepath.Join(root, "none")) + // The account's runtime directory, through which its service manager is reached. + if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil { + t.Fatal(err) + } + bin := fakeBinaries(t, map[string]string{"systemd-run": `echo "$*" > "$LOG"`, "systemctl": `true`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + got, err := Run("htop.desktop", nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Argv, []string{"rofi-sensible-terminal", "-e", "htop"}) || !strings.HasPrefix(got.Unit, "rofi-run-") { + t.Fatalf("%+v", got) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 --setenv=XDG_SESSION_ID=2 -- rofi-sensible-terminal -e htop") { + t.Fatalf("systemd-run was asked: %s", asked) + } + if _, err := Run("", nil); err == nil { + t.Fatal("nothing to run was accepted") + } + if _, err := Run("nope.desktop", nil); err == nil { + t.Fatal("a missing entry was accepted") + } +} + +func TestTheDmenuCommandHandsRofiWhatItUnderstands(t *testing.T) { + fakeBinaries(t, map[string]string{"rofi": `for a in "$@"; do printf '[%s]' "$a"; done`}) + shim, _ := filepath.Abs(filepath.Join("..", "..", "files", "bin", "dmenu")) + out, err := exec.Command(shim, "-b", "-fn", "Mono 10", "-l", "8", "-p", "pick one", "-nb", "#000", "-i").Output() + if err != nil { + t.Fatal(err) + } + if string(out) != "[-dmenu][-l][8][-p][pick one][-i]" { + t.Fatalf("rofi was handed %s", out) + } +} diff --git a/modules/rofi/cmd/rofi-tools/session.go b/modules/rofi/cmd/rofi-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/session.go @@ -0,0 +1,423 @@ +// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208). +// +// The runtime is a system service running as the operator account (ADR 0175): it has the account's +// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that +// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of +// the account that is part of the session (the window manager first), the same thing `loginctl` and +// a person's own shell would point at, and says where it found them. +// +// Long-lived programs a tool starts go to the account's own service manager through `systemd-run +// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties +// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would +// die with it. +// +// This file is the same in every desktop module that carries it; it moves into the Go SDK once a +// second consumer outside the desktop wants it. +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Where the session is looked for. Variables so a test can point them at a fake tree. +var ( + procRoot = "/proc" + runUserDir = "/run/user" + x11Sockets = "/tmp/.X11-unix" +) + +// sessionHolders are the processes whose environment is the session's, best first: the window +// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them. +var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"} + +// sessionKeys are the variables a session carries that a tool hands on to what it runs. +var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", + "XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"} + +// Session is what a tool needs to reach the operator's desktop. +type Session struct { + UID int `json:"uid"` + Display string `json:"display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + Wayland string `json:"wayland_display,omitempty"` + Bus string `json:"bus,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + SessionID string `json:"session_id,omitempty"` + I3Sock string `json:"i3sock,omitempty"` + // From says where the values were found: the tool's own environment, a process, or the socket. + From string `json:"from"` +} + +// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. +var ErrNoSession = errors.New("no graphical session") + +// ErrTimedOut is what run answers for a command ended because it ran past its time. +var ErrTimedOut = errors.New("timed out") + +// ErrNoBus is answered by a tool that needs the session bus when the account has none. +var ErrNoBus = errors.New("no session bus") + +// operatorHome is the account's home: what the runtime was told, else the process's own. +func operatorHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// findSession finds the graphical session of the account this tool runs as, or answers +// ErrNoSession with what it looked at. +func findSession() (Session, error) { + s := findEnvironment() + if s.Display == "" && s.Wayland == "" { + return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+ + "or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+ + "Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets) + } + return s, nil +} + +// findBus finds the account's session bus, which a logged-in account has whether or not a desktop +// is running. +func findBus() (Session, error) { + s := findEnvironment() + if s.Bus == "" { + return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+ + "(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus")) + } + return s, nil +} + +func findEnvironment() Session { + uid := os.Getuid() + s := Session{UID: uid} + own := map[string]string{} + for _, k := range sessionKeys { + own[k] = os.Getenv(k) + } + if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" { + s.fill(own) + s.From = "the tool's own environment" + } else if pid, comm, env, ok := sessionProcess(uid); ok { + s.fill(env) + s.From = fmt.Sprintf("process %s (pid %d)", comm, pid) + } else if display, ok := lonelyX11Socket(); ok { + if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) { + s.Display, s.XAuthority = display, a + s.From = "the X server socket and the account's ~/.Xauthority" + } + s.fill(own) + } else { + s.fill(own) + s.From = "nothing: no session found" + } + // The bus and the runtime directory are the account's, whether or not the process named them. + runtime := filepath.Join(runUserDir, strconv.Itoa(uid)) + if s.RuntimeDir == "" && exists(runtime) { + s.RuntimeDir = runtime + } + if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + return s +} + +func (s *Session) fill(env map[string]string) { + set := func(dst *string, key string) { + if *dst == "" { + *dst = env[key] + } + } + set(&s.Display, "DISPLAY") + set(&s.XAuthority, "XAUTHORITY") + set(&s.Wayland, "WAYLAND_DISPLAY") + set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS") + set(&s.RuntimeDir, "XDG_RUNTIME_DIR") + set(&s.SessionID, "XDG_SESSION_ID") + set(&s.I3Sock, "I3SOCK") +} + +// sessionProcess is the best process of this uid whose environment names a display. +func sessionProcess(uid int) (int, string, map[string]string, bool) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0, "", nil, false + } + type candidate struct { + pid int + comm string + env map[string]string + rank int + } + var found []candidate + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + env := parseEnviron(raw) + if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" { + continue + } + comm := readTrimmed(filepath.Join(dir, "comm")) + rank := len(sessionHolders) + for i, h := range sessionHolders { + if h == comm { + rank = i + break + } + } + found = append(found, candidate{pid, comm, env, rank}) + } + if len(found) == 0 { + return 0, "", nil, false + } + sort.Slice(found, func(i, j int) bool { + if found[i].rank != found[j].rank { + return found[i].rank < found[j].rank + } + return found[i].pid > found[j].pid // the newer of two equals + }) + best := found[0] + return best.pid, best.comm, best.env, true +} + +func parseEnviron(raw []byte) map[string]string { + env := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + if i := bytes.IndexByte(kv, '='); i > 0 { + env[string(kv[:i])] = string(kv[i+1:]) + } + } + return env +} + +func ownerOf(path string) (int, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, false + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return int(st.Uid), true +} + +// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one. +func lonelyX11Socket() (string, bool) { + entries, err := os.ReadDir(x11Sockets) + if err != nil { + return "", false + } + var displays []string + for _, e := range entries { + if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() { + if _, err := strconv.Atoi(n); err == nil { + displays = append(displays, ":"+n) + } + } + } + if len(displays) != 1 { + return "", false + } + return displays[0], true +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Env is this process's environment with the session's variables in place of its own. +func (s Session) Env() []string { + drop := map[string]bool{} + for _, k := range sessionKeys { + drop[k] = true + } + var env []string + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] { + continue + } + env = append(env, kv) + } + add := func(k, v string) { + if v != "" { + env = append(env, k+"="+v) + } + } + add("DISPLAY", s.Display) + add("XAUTHORITY", s.XAuthority) + add("WAYLAND_DISPLAY", s.Wayland) + add("DBUS_SESSION_BUS_ADDRESS", s.Bus) + add("XDG_RUNTIME_DIR", s.RuntimeDir) + add("XDG_SESSION_ID", s.SessionID) + add("I3SOCK", s.I3Sock) + return env +} + +// mostOutput bounds what a command may answer with, per stream. +const mostOutput = 256 << 10 + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr,omitempty"` + Code int `json:"code"` + Truncated bool `json:"truncated,omitempty"` +} + +// run runs a command in the session's environment, its input given, ended with everything it +// started after timeout. A command that is not installed is an error naming it; one that exits +// non-zero is a Result with its code, for the caller to judge. +func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) { + path, err := exec.LookPath(name) + if err != nil { + return Result{}, fmt.Errorf("%s is not installed on this machine", name) + } + cmd := exec.Command(path, args...) + cmd.Env = s.Env() + if home := operatorHome(); exists(home) { + cmd.Dir = home + } + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errOut capped + cmd.Stdout, cmd.Stderr = &out, &errOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return Result{}, fmt.Errorf("%s: %w", name, err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err = <-done: + case <-time.After(timeout): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-done + return Result{Stdout: out.String(), Stderr: errOut.String()}, + fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut) + } + r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut} + var exit *exec.ExitError + if errors.As(err, &exit) { + r.Code = exit.ExitCode() + } else if err != nil { + return r, fmt.Errorf("%s: %w", name, err) + } + return r, nil +} + +// detach starts a long-lived program under the account's own service manager, as a transient unit +// that carries the session's display, so it outlives the runtime that asked for it. A unit already +// running under the same name is stopped first, so a fixed name means "at most one". +func (s Session) detach(unit string, args ...string) error { + if s.RuntimeDir == "" { + return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+ + "cannot be reached", ErrNoBus) + } + _, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service") + call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, + {"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} { + if kv[1] != "" { + call = append(call, "--setenv="+kv[0]+"="+kv[1]) + } + } + call = append(call, "--") + call = append(call, args...) + r, err := s.run(10*time.Second, "", "systemd-run", call...) + if err != nil { + return err + } + if r.Code != 0 { + return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr)) + } + return nil +} + +// uniqueUnit is a transient unit name that will not collide with an earlier one. +func uniqueUnit(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +type capped struct { + bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := mostOutput - c.Len(); room < len(p) { + if room > 0 { + c.Buffer.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.Buffer.Write(p) +} + +// processesOf are the pids of this uid's processes whose command name is comm, oldest first. +func processesOf(comm string) []int { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil + } + uid := os.Getuid() + var pids []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + if readTrimmed(filepath.Join(dir, "comm")) == comm { + pids = append(pids, pid) + } + } + sort.Ints(pids) + return pids +} + +// signalAll sends sig to every process of this uid named comm, and answers the pids it reached. +func signalAll(comm string, sig syscall.Signal) []int { + var reached []int + for _, pid := range processesOf(comm) { + if syscall.Kill(pid, sig) == nil { + reached = append(reached, pid) + } + } + return reached +} diff --git a/modules/rofi/cmd/rofi-tools/session_test.go b/modules/rofi/cmd/rofi-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/rofi/cmd/rofi-tools/session_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with +// none of the test process's own session variables, and gives back the root. +func fakeMachine(t *testing.T) string { + t.Helper() + root := t.TempDir() + procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11") + for _, d := range []string{procRoot, runUserDir, x11Sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, k := range sessionKeys { + t.Setenv(k, "") + } + t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home")) + t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" }) + return root +} + +func fakeProcess(t *testing.T, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(procRoot, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere") + fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied") + fakeProcess(t, 50, "bash", "PATH=/usr/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") { + t.Fatalf("the window manager's environment: %+v", s) + } + for _, kv := range s.Env() { + if strings.HasPrefix(kv, "SECRET_TOKEN=") { + t.Fatal("a variable of the session process that is not a session variable was handed on") + } + } +} + +func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "firefox", "DISPLAY=:0") + fakeProcess(t, 20, "firefox", "DISPLAY=:2") + s, err := findSession() + if err != nil || s.Display != ":2" { + t.Fatalf("the newest of two equals: %+v, %v", s, err) + } +} + +func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "sshd", "PATH=/usr/bin") + _, err := findSession() + if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") { + t.Fatalf("no session: %v", err) + } +} + +func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) { + root := fakeMachine(t) + if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findSession() + if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") { + t.Fatalf("socket and authority: %+v, %v", s, err) + } +} + +func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) { + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + if _, err := findBus(); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory is no bus: %v", err) + } + if err := os.MkdirAll(runtime, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findBus() + if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime { + t.Fatalf("bus: %+v, %v", s, err) + } + env := strings.Join(s.Env(), "\n") + if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") { + t.Fatalf("the bus is handed on: %s", env) + } +} + +func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) { + fakeMachine(t) + s := Session{} + r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3") + if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("result: %+v, %v", r, err) + } + start := time.Now() + if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second { + t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start)) + } + if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("a missing program: %v", err) + } +} + +func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) { + fakeMachine(t) + bin := fakeBinaries(t, map[string]string{ + "systemctl": `echo "systemctl $*" >> "$LOG"`, + "systemd-run": `echo "systemd-run $*" >> "$LOG"`, + }) + log := filepath.Join(bin, "log") + t.Setenv("LOG", log) + s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"} + if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(log) + want := "systemctl --user stop picom-session.service\n" + + "systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n" + if string(got) != want { + t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want) + } + if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory: %v", err) + } +} + +// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory. +func fakeBinaries(t *testing.T, scripts map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, body := range scripts { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} diff --git a/modules/rofi/files/bin/dmenu b/modules/rofi/files/bin/dmenu new file mode 100755 index 0000000..b7460e9 --- /dev/null +++ b/modules/rofi/files/bin/dmenu @@ -0,0 +1,26 @@ +#!/bin/sh +# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher +# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output, +# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat. +# +# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w) +# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The +# look is the launcher's theme. +set -u +n=$# +while [ "$n" -gt 0 ]; do + arg=$1 + shift + n=$((n - 1)) + case $arg in + -fn | -nb | -nf | -sb | -sf | -m | -w) + if [ "$n" -gt 0 ]; then + shift + n=$((n - 1)) + fi + ;; + -b | -f) ;; + *) set -- "$@" "$arg" ;; + esac +done +exec rofi -dmenu "$@" diff --git a/modules/rofi/files/bin/rofi-launch b/modules/rofi/files/bin/rofi-launch new file mode 100755 index 0000000..788f23c --- /dev/null +++ b/modules/rofi/files/bin/rofi-launch @@ -0,0 +1,39 @@ +#!/bin/sh +# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's +# key bindings run. +# +# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press +# that opens nothing. So a failure within half a second is tried again, up to five times. A person +# closing the menu takes longer than that, and is never shown it a second time. +set -u +mode=${1:-drun} + +now_ms() { date +%s%3N; } + +attempt() { + i=0 + while [ "$i" -lt 5 ]; do + start=$(now_ms) + "$@" && return 0 + [ $(($(now_ms) - start)) -ge 500 ] && return 1 + i=$((i + 1)) + sleep 0.1 + done + return 1 +} + +case $mode in +drun | run | window | filebrowser) + attempt rofi -show "$mode" + ;; +sudo) + # A command line, run with sudo in the node's terminal. + command=$(attempt rofi -dmenu -p sudo &2 + exit 2 + ;; +esac diff --git a/modules/rofi/files/bin/rofi-powermenu b/modules/rofi/files/bin/rofi-powermenu new file mode 100755 index 0000000..2bdf64c --- /dev/null +++ b/modules/rofi/files/bin/rofi-powermenu @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down. +# +# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a +# verb of logind or the service manager, so nothing here names a window manager or a locker: +# - lock asks logind to lock the session, which the holder of node-lock-screen answers; +# - log out ends this login session, whichever window manager runs in it. +# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2). +set -u + +theme=mesh-powermenu +uptime="$(uptime -p | sed -e 's/^up //')" + +shutdown='󰤂' +reboot='󰜉' +lock='󰌾' +suspend='󰤄' +logout='󰍃' +yes='󰄲' +no='󰅖' + +menu() { + rofi -dmenu -p "Uptime: $uptime" -mesg "Uptime: $uptime" -theme "$theme" +} + +confirm() { + printf '%s\n%s\n' "$yes" "$no" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme "$theme" \ + -theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \ + -theme-str 'mainbox {children: [ "message", "listview" ];}' \ + -theme-str 'listview {columns: 2; lines: 1;}' \ + -theme-str 'element-text {horizontal-align: 0.5;}' \ + -theme-str 'textbox {horizontal-align: 0.5;}' +} + +session() { + # The login session this menu runs in: the session's own id, else logind's answer for this process. + if [ -n "${XDG_SESSION_ID:-}" ]; then + echo "$XDG_SESSION_ID" + else + loginctl show-session auto -p Id --value 2>/dev/null + fi +} + +confirmed() { [ "$(confirm)" = "$yes" ]; } + +chosen="$(printf '%s\n' "$lock" "$suspend" "$logout" "$reboot" "$shutdown" | menu)" || exit 0 +case $chosen in +"$lock") loginctl lock-session "$(session)" ;; +"$suspend") confirmed && systemctl suspend ;; +"$logout") confirmed && loginctl terminate-session "$(session)" ;; +"$reboot") confirmed && systemctl reboot ;; +"$shutdown") confirmed && systemctl poweroff ;; +esac +exit 0 diff --git a/modules/rofi/files/config.rasi b/modules/rofi/files/config.rasi new file mode 100644 index 0000000..d123371 --- /dev/null +++ b/modules/rofi/files/config.rasi @@ -0,0 +1,54 @@ +/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at + * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from + * rofi's defaults, nothing else. Its look is the theme "mesh", in themes/ beside this file. */ + +configuration { + modi: "drun,run,window,filebrowser"; + show-icons: true; + case-sensitive: false; + normalize-match: true; + matching: "normal"; + tokenize: true; + cycle: true; + steal-focus: false; + click-to-exit: true; + sort: false; + + /* Applications: the user's and the system's desktop entries, read fresh each time so a + * program installed a minute ago is there. */ + drun-match-fields: "name,generic,exec,categories,keywords"; + drun-display-format: "{name} [({generic})]"; + drun-show-actions: false; + drun-url-launcher: "xdg-open"; + drun-use-desktop-cache: false; + drun-reload-desktop-cache: false; + run,drun { + fallback-icon: "application-x-addon"; + } + + /* A command run from `run` opens in the node's terminal when it asks for one. */ + terminal: "rofi-sensible-terminal"; + run-shell-command: "{terminal} -e {cmd}"; + ssh-command: "{terminal} -e {ssh-client} {host} [-p {port}]"; + parse-hosts: true; + parse-known-hosts: true; + + window-match-fields: "title,class,role,name,desktop"; + window-format: "{w} - {c} - {t:0}"; + + disable-history: false; + max-history-size: 25; + + filebrowser { + directories-first: true; + sorting-method: "name"; + } + + display-window: "Windows"; + display-run: "Run"; + display-ssh: "SSH"; + display-drun: "Apps"; + display-filebrowser: "Files"; +} + +@theme "mesh" diff --git a/modules/rofi/files/i3/50-rofi.conf b/modules/rofi/files/i3/50-rofi.conf new file mode 100644 index 0000000..1395d2c --- /dev/null +++ b/modules/rofi/files/i3/50-rofi.conf @@ -0,0 +1,8 @@ +# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after +# the variables it sets, so $mod is i3's. +bindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun +bindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run +bindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo +bindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window +bindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu diff --git a/modules/rofi/files/themes/mesh-powermenu.rasi b/modules/rofi/files/themes/mesh-powermenu.rasi new file mode 100644 index 0000000..5b837f4 --- /dev/null +++ b/modules/rofi/files/themes/mesh-powermenu.rasi @@ -0,0 +1,172 @@ +/** + * The theme "mesh-powermenu" (module rofi, novox/hq ADR 0208): the power menu's five buttons. + * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of + * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined. + **/ + +/*****----- Configuration -----*****/ +configuration { + show-icons: false; +} + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; +} + + +/*****----- Main Window -----*****/ +window { + /* properties for window widget */ + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 800px; + x-offset: 0px; + y-offset: 0px; + + /* properties for all widgets */ + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + cursor: "default"; + background-color: @background; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 30px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + children: [ "inputbar", "listview" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + children: [ "textbox-prompt-colon", "prompt"]; +} + +dummy { + background-color: transparent; +} + +textbox-prompt-colon { + enabled: true; + expand: false; + str: ""; + padding: 12px 16px; + border-radius: 0px; + background-color: @urgent; + text-color: @background; +} +prompt { + enabled: true; + padding: 12px; + border-radius: 0px; + background-color: @active; + text-color: @background; +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; +} +textbox { + background-color: inherit; + text-color: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; + placeholder-color: @foreground; + blink: true; + markup: true; +} +error-message { + padding: 12px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background; + text-color: @foreground; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 5; + lines: 1; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + + spacing: 15px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: transparent; + text-color: @foreground; + cursor: "default"; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 0px; + margin: 0px; + padding: 40px 10px; + border: 0px solid; + border-radius: 0px; + border-color: @selected; + background-color: @background-alt; + text-color: @foreground; + cursor: pointer; +} +element-text { + font: "JetBrainsMono Nerd Font Bold 32"; + background-color: transparent; + text-color: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.5; +} +element selected.normal { + background-color: var(selected); + text-color: var(background); +} diff --git a/modules/rofi/files/themes/mesh.rasi b/modules/rofi/files/themes/mesh.rasi new file mode 100644 index 0000000..8be0f55 --- /dev/null +++ b/modules/rofi/files/themes/mesh.rasi @@ -0,0 +1,263 @@ +/** + * The theme "mesh" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names + * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of + * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is + * one file, and its face the monospace one every desktop module names. + **/ + +/*****----- Global Properties -----*****/ +* { + background: #000000FF; + background-alt: #282B31FF; + foreground: #FFFFFFFF; + selected: #DE5200FF; + active: #DE5200FF; + urgent: #CC0000FF; + + font: "JetBrainsMono Nerd Font 11"; + + border-colour: var(selected); + handle-colour: var(selected); + background-colour: var(background); + foreground-colour: var(foreground); + alternate-background: var(background-alt); + normal-background: var(background); + normal-foreground: var(foreground); + urgent-background: var(urgent); + urgent-foreground: var(background); + active-background: var(active); + active-foreground: var(background); + selected-normal-background: var(selected); + selected-normal-foreground: var(background); + selected-urgent-background: var(active); + selected-urgent-foreground: var(background); + selected-active-background: var(urgent); + selected-active-foreground: var(background); + alternate-normal-background: var(background); + alternate-normal-foreground: var(foreground); + alternate-urgent-background: var(urgent); + alternate-urgent-foreground: var(background); + alternate-active-background: var(active); + alternate-active-foreground: var(background); +} + +/*****----- Main Window -----*****/ +window { + transparency: "real"; + location: center; + anchor: center; + fullscreen: false; + width: 600px; + x-offset: 0px; + y-offset: 0px; + enabled: true; + margin: 0px; + padding: 0px; + border: 1px solid; + border-radius: 0px; + border-color: @border-colour; + cursor: "default"; + background-color: @background-colour; +} + +/*****----- Main Box -----*****/ +mainbox { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + children: [ "inputbar", "message", "listview", "mode-switcher" ]; +} + +/*****----- Inputbar -----*****/ +inputbar { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px 0px 1px 0px; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + children: [ "prompt", "entry" ]; +} + +prompt { + enabled: true; + background-color: inherit; + text-color: inherit; +} +textbox-prompt-colon { + enabled: true; + expand: false; + str: "::"; + background-color: inherit; + text-color: inherit; +} +entry { + enabled: true; + background-color: inherit; + text-color: inherit; + cursor: text; + placeholder: "Search..."; + placeholder-color: inherit; +} + +/*****----- Listview -----*****/ +listview { + enabled: true; + columns: 1; + lines: 8; + cycle: true; + dynamic: true; + scrollbar: false; + layout: vertical; + reverse: false; + fixed-height: true; + fixed-columns: true; + spacing: 0px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: "default"; +} +scrollbar { + handle-width: 5px; + handle-color: @handle-colour; + border-radius: 0px; + background-color: @alternate-background; +} + +/*****----- Elements -----*****/ +element { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; + cursor: pointer; +} +element normal.normal { + background-color: var(normal-background); + text-color: var(normal-foreground); +} +element normal.urgent { + background-color: var(urgent-background); + text-color: var(urgent-foreground); +} +element normal.active { + background-color: var(active-background); + text-color: var(active-foreground); +} +element selected.normal { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} +element selected.urgent { + background-color: var(selected-urgent-background); + text-color: var(selected-urgent-foreground); +} +element selected.active { + background-color: var(selected-active-background); + text-color: var(selected-active-foreground); +} +element alternate.normal { + background-color: var(alternate-normal-background); + text-color: var(alternate-normal-foreground); +} +element alternate.urgent { + background-color: var(alternate-urgent-background); + text-color: var(alternate-urgent-foreground); +} +element alternate.active { + background-color: var(alternate-active-background); + text-color: var(alternate-active-foreground); +} +element-icon { + background-color: transparent; + text-color: inherit; + size: 24px; + cursor: inherit; +} +element-text { + background-color: transparent; + text-color: inherit; + highlight: inherit; + cursor: inherit; + vertical-align: 0.5; + horizontal-align: 0.0; +} + +/*****----- Mode Switcher -----*****/ +mode-switcher { + enabled: true; + spacing: 10px; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +button { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: inherit; + cursor: pointer; +} +button selected { + background-color: var(selected-normal-background); + text-color: var(selected-normal-foreground); +} + +/*****----- Message -----*****/ +message { + enabled: true; + margin: 0px; + padding: 0px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: transparent; + text-color: @foreground-colour; +} +textbox { + font: "Inter 11"; + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @alternate-background; + text-color: @foreground-colour; + vertical-align: 0.5; + horizontal-align: 0.0; + highlight: none; + placeholder-color: @foreground-colour; + blink: true; + markup: true; +} +error-message { + padding: 10px; + border: 0px solid; + border-radius: 0px; + border-color: @border-colour; + background-color: @background-colour; + text-color: @foreground-colour; +} diff --git a/modules/rofi/go.mod b/modules/rofi/go.mod new file mode 100644 index 0000000..db7646b --- /dev/null +++ b/modules/rofi/go.mod @@ -0,0 +1,5 @@ +module rofi + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/rofi/go.sum b/modules/rofi/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/rofi/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/rofi/module.json b/modules/rofi/module.json new file mode 100644 index 0000000..85eafab --- /dev/null +++ b/modules/rofi/module.json @@ -0,0 +1,116 @@ +{ + "module": "rofi", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-launcher", + "scope": "node", + "serves": [ + "menu" + ] + } + ], + "tools": [ + "rofi_applications", + "rofi_themes", + "rofi_run" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "rofi" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "themes-dir", + "type": "directory", + "path": "${machine:account-home}/.config/rofi/themes", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/rofi/config.rasi", + "owner": "${machine:account}", + "mode": "0644", + "content": "/* rofi, the launcher (module rofi, novox/hq ADR 0208). Owned by the mesh: this file is replaced at\n * every push. Adopted from the two workstations' file of 2026-10-04: the settings that differ from\n * rofi's defaults, nothing else. Its look is the theme \"mesh\", in themes/ beside this file. */\n\nconfiguration {\n\tmodi: \"drun,run,window,filebrowser\";\n\tshow-icons: true;\n\tcase-sensitive: false;\n\tnormalize-match: true;\n\tmatching: \"normal\";\n\ttokenize: true;\n\tcycle: true;\n\tsteal-focus: false;\n\tclick-to-exit: true;\n\tsort: false;\n\n\t/* Applications: the user's and the system's desktop entries, read fresh each time so a\n\t * program installed a minute ago is there. */\n\tdrun-match-fields: \"name,generic,exec,categories,keywords\";\n\tdrun-display-format: \"{name} [({generic})]\";\n\tdrun-show-actions: false;\n\tdrun-url-launcher: \"xdg-open\";\n\tdrun-use-desktop-cache: false;\n\tdrun-reload-desktop-cache: false;\n\trun,drun {\n\t\tfallback-icon: \"application-x-addon\";\n\t}\n\n\t/* A command run from `run` opens in the node's terminal when it asks for one. */\n\tterminal: \"rofi-sensible-terminal\";\n\trun-shell-command: \"{terminal} -e {cmd}\";\n\tssh-command: \"{terminal} -e {ssh-client} {host} [-p {port}]\";\n\tparse-hosts: true;\n\tparse-known-hosts: true;\n\n\twindow-match-fields: \"title,class,role,name,desktop\";\n\twindow-format: \"{w} - {c} - {t:0}\";\n\n\tdisable-history: false;\n\tmax-history-size: 25;\n\n\tfilebrowser {\n\t\tdirectories-first: true;\n\t\tsorting-method: \"name\";\n\t}\n\n\tdisplay-window: \"Windows\";\n\tdisplay-run: \"Run\";\n\tdisplay-ssh: \"SSH\";\n\tdisplay-drun: \"Apps\";\n\tdisplay-filebrowser: \"Files\";\n}\n\n@theme \"mesh\"\n" + }, + { + "id": "theme", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh.rasi", + "owner": "${machine:account}", + "mode": "0644", + "content": "/**\n * The theme \"mesh\" (module rofi, novox/hq ADR 0208): every rofi window unless a caller names\n * another. Owned by the mesh; replaced at every push. Adopted from the workstations' theme of\n * 2026-10-04 (after adi1090x's launcher type-4, style-1), its colour file inlined so the theme is\n * one file, and its face the monospace one every desktop module names.\n **/\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n\n border-colour: var(selected);\n handle-colour: var(selected);\n background-colour: var(background);\n foreground-colour: var(foreground);\n alternate-background: var(background-alt);\n normal-background: var(background);\n normal-foreground: var(foreground);\n urgent-background: var(urgent);\n urgent-foreground: var(background);\n active-background: var(active);\n active-foreground: var(background);\n selected-normal-background: var(selected);\n selected-normal-foreground: var(background);\n selected-urgent-background: var(active);\n selected-urgent-foreground: var(background);\n selected-active-background: var(urgent);\n selected-active-foreground: var(background);\n alternate-normal-background: var(background);\n alternate-normal-foreground: var(foreground);\n alternate-urgent-background: var(urgent);\n alternate-urgent-foreground: var(background);\n alternate-active-background: var(active);\n alternate-active-foreground: var(background);\n}\n\n/*****----- Main Window -----*****/\nwindow {\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 600px;\n x-offset: 0px;\n y-offset: 0px;\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 1px solid;\n border-radius: 0px;\n border-color: @border-colour;\n cursor: \"default\";\n background-color: @background-colour;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n children: [ \"inputbar\", \"message\", \"listview\", \"mode-switcher\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px 0px 1px 0px;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n children: [ \"prompt\", \"entry\" ];\n}\n\nprompt {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n}\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"::\";\n background-color: inherit;\n text-color: inherit;\n}\nentry {\n enabled: true;\n background-color: inherit;\n text-color: inherit;\n cursor: text;\n placeholder: \"Search...\";\n placeholder-color: inherit;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 1;\n lines: 8;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n spacing: 0px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: \"default\";\n}\nscrollbar {\n handle-width: 5px;\n handle-color: @handle-colour;\n border-radius: 0px;\n background-color: @alternate-background;\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n cursor: pointer;\n}\nelement normal.normal {\n background-color: var(normal-background);\n text-color: var(normal-foreground);\n}\nelement normal.urgent {\n background-color: var(urgent-background);\n text-color: var(urgent-foreground);\n}\nelement normal.active {\n background-color: var(active-background);\n text-color: var(active-foreground);\n}\nelement selected.normal {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\nelement selected.urgent {\n background-color: var(selected-urgent-background);\n text-color: var(selected-urgent-foreground);\n}\nelement selected.active {\n background-color: var(selected-active-background);\n text-color: var(selected-active-foreground);\n}\nelement alternate.normal {\n background-color: var(alternate-normal-background);\n text-color: var(alternate-normal-foreground);\n}\nelement alternate.urgent {\n background-color: var(alternate-urgent-background);\n text-color: var(alternate-urgent-foreground);\n}\nelement alternate.active {\n background-color: var(alternate-active-background);\n text-color: var(alternate-active-foreground);\n}\nelement-icon {\n background-color: transparent;\n text-color: inherit;\n size: 24px;\n cursor: inherit;\n}\nelement-text {\n background-color: transparent;\n text-color: inherit;\n highlight: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n}\n\n/*****----- Mode Switcher -----*****/\nmode-switcher {\n enabled: true;\n spacing: 10px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\nbutton {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: inherit;\n cursor: pointer;\n}\nbutton selected {\n background-color: var(selected-normal-background);\n text-color: var(selected-normal-foreground);\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: transparent;\n text-color: @foreground-colour;\n}\ntextbox {\n font: \"Inter 11\";\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @alternate-background;\n text-color: @foreground-colour;\n vertical-align: 0.5;\n horizontal-align: 0.0;\n highlight: none;\n placeholder-color: @foreground-colour;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @border-colour;\n background-color: @background-colour;\n text-color: @foreground-colour;\n}\n" + }, + { + "id": "theme-powermenu", + "type": "file", + "path": "${machine:account-home}/.config/rofi/themes/mesh-powermenu.rasi", + "owner": "${machine:account}", + "mode": "0644", + "content": "/**\n * The theme \"mesh-powermenu\" (module rofi, novox/hq ADR 0208): the power menu's five buttons.\n * Owned by the mesh; replaced at every push. Adopted from the workstations' power menu theme of\n * 2026-10-04 (after adi1090x's powermenu type-2, style-1), its colours inlined.\n **/\n\n/*****----- Configuration -----*****/\nconfiguration {\n show-icons: false;\n}\n\n/*****----- Global Properties -----*****/\n* {\n background: #000000FF;\n background-alt: #282B31FF;\n foreground: #FFFFFFFF;\n selected: #DE5200FF;\n active: #DE5200FF;\n urgent: #CC0000FF;\n\n font: \"JetBrainsMono Nerd Font 11\";\n}\n\n\n/*****----- Main Window -----*****/\nwindow {\n /* properties for window widget */\n transparency: \"real\";\n location: center;\n anchor: center;\n fullscreen: false;\n width: 800px;\n x-offset: 0px;\n y-offset: 0px;\n\n /* properties for all widgets */\n enabled: true;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n cursor: \"default\";\n background-color: @background;\n}\n\n/*****----- Main Box -----*****/\nmainbox {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 30px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n children: [ \"inputbar\", \"listview\" ];\n}\n\n/*****----- Inputbar -----*****/\ninputbar {\n enabled: true;\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n children: [ \"textbox-prompt-colon\", \"prompt\"];\n}\n\ndummy {\n background-color: transparent;\n}\n\ntextbox-prompt-colon {\n enabled: true;\n expand: false;\n str: \"\";\n padding: 12px 16px;\n border-radius: 0px;\n background-color: @urgent;\n text-color: @background;\n}\nprompt {\n enabled: true;\n padding: 12px;\n border-radius: 0px;\n background-color: @active;\n text-color: @background;\n}\n\n/*****----- Message -----*****/\nmessage {\n enabled: true;\n margin: 0px;\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n}\ntextbox {\n background-color: inherit;\n text-color: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n placeholder-color: @foreground;\n blink: true;\n markup: true;\n}\nerror-message {\n padding: 12px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background;\n text-color: @foreground;\n}\n\n/*****----- Listview -----*****/\nlistview {\n enabled: true;\n columns: 5;\n lines: 1;\n cycle: true;\n dynamic: true;\n scrollbar: false;\n layout: vertical;\n reverse: false;\n fixed-height: true;\n fixed-columns: true;\n\n spacing: 15px;\n margin: 0px;\n padding: 0px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: transparent;\n text-color: @foreground;\n cursor: \"default\";\n}\n\n/*****----- Elements -----*****/\nelement {\n enabled: true;\n spacing: 0px;\n margin: 0px;\n padding: 40px 10px;\n border: 0px solid;\n border-radius: 0px;\n border-color: @selected;\n background-color: @background-alt;\n text-color: @foreground;\n cursor: pointer;\n}\nelement-text {\n font: \"JetBrainsMono Nerd Font Bold 32\";\n background-color: transparent;\n text-color: inherit;\n cursor: inherit;\n vertical-align: 0.5;\n horizontal-align: 0.5;\n}\nelement selected.normal {\n background-color: var(selected);\n text-color: var(background);\n}\n" + }, + { + "id": "dmenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/dmenu", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/bin/sh\n# dmenu, as this node's launcher answers it (module rofi, novox/hq ADR 0208). The node-launcher\n# seat's dmenu-compatible command: choices on standard input, the chosen one on standard output,\n# exit 1 when nothing was chosen. A script calls `dmenu` and works whichever module holds the seat.\n#\n# rofi understands dmenu's -p, -l and -i. dmenu's look options (-fn, -nb, -nf, -sb, -sf, -m, -w)\n# take a value rofi would misread, so they are dropped with it; -b and -f are dropped alone. The\n# look is the launcher's theme.\nset -u\nn=$#\nwhile [ \"$n\" -gt 0 ]; do\n\targ=$1\n\tshift\n\tn=$((n - 1))\n\tcase $arg in\n\t-fn | -nb | -nf | -sb | -sf | -m | -w)\n\t\tif [ \"$n\" -gt 0 ]; then\n\t\t\tshift\n\t\t\tn=$((n - 1))\n\t\tfi\n\t\t;;\n\t-b | -f) ;;\n\t*) set -- \"$@\" \"$arg\" ;;\n\tesac\ndone\nexec rofi -dmenu \"$@\"\n" + }, + { + "id": "launch", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-launch", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/bin/sh\n# rofi-launch drun|run|window|filebrowser|sudo (module rofi, novox/hq ADR 0208): what the launcher's\n# key bindings run.\n#\n# After a resume the keyboard grab can fail for a moment, and rofi then exits at once: a key press\n# that opens nothing. So a failure within half a second is tried again, up to five times. A person\n# closing the menu takes longer than that, and is never shown it a second time.\nset -u\nmode=${1:-drun}\n\nnow_ms() { date +%s%3N; }\n\nattempt() {\n\ti=0\n\twhile [ \"$i\" -lt 5 ]; do\n\t\tstart=$(now_ms)\n\t\t\"$@\" && return 0\n\t\t[ $(($(now_ms) - start)) -ge 500 ] && return 1\n\t\ti=$((i + 1))\n\t\tsleep 0.1\n\tdone\n\treturn 1\n}\n\ncase $mode in\ndrun | run | window | filebrowser)\n\tattempt rofi -show \"$mode\"\n\t;;\nsudo)\n\t# A command line, run with sudo in the node's terminal.\n\tcommand=$(attempt rofi -dmenu -p sudo &2\n\texit 2\n\t;;\nesac\n" + }, + { + "id": "powermenu", + "type": "file", + "path": "${machine:account-home}/.local/bin/rofi-powermenu", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n" + }, + { + "id": "i3-bindings", + "type": "file", + "path": "${machine:account-home}/.config/i3/config.d/50-rofi.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/rofi-tools", + "binary": "rofi-tools", + "loads": [ + "rofi-tools" + ] + } + ] + } +}