rofi: the launcher as a module, claiming node-launcher and serving menu (hq ADR 0208)
Places the seat's dmenu-compatible command, the launcher and power menu, its themes in the decided faces, and its key bindings as an i3 drop-in; Go tools menu, applications, themes and run.
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 <seat>.<verb>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+26
@@ -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 "$@"
|
||||
Executable
+39
@@ -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 </dev/null) || exit 0
|
||||
[ -n "$command" ] || exit 0
|
||||
exec rofi-sensible-terminal -e sudo sh -c "$command"
|
||||
;;
|
||||
*)
|
||||
echo "rofi-launch: no mode $mode (drun, run, window, filebrowser, sudo)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+54
@@ -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
|
||||
@@ -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} [<span weight='light' size='small'><i>({generic})</i></span>]";
|
||||
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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module rofi
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user