diff --git a/modules/adwaita/README.md b/modules/adwaita/README.md new file mode 100644 index 0000000..c8ff628 --- /dev/null +++ b/modules/adwaita/README.md @@ -0,0 +1,103 @@ +# adwaita + +The desktop's theme as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 6): Adwaita +for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, because themes coexist. + +- **Packages:** `gnome-themes-extra` (Adwaita-dark for GTK 2 and 3), `adwaita-icon-theme`, + `adwaita-cursors`, `qt6ct`, `xdg-desktop-portal-gtk`. +- **Environment** (ADR 0203), the five words today's `~/.xinitrc` exported plus the cursor: + - `GTK_THEME=Adwaita:dark`; + - `GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc`; + - `QT_QPA_PLATFORMTHEME=qt6ct`; + - `QT_STYLE_OVERRIDE=Fusion`; + - `QT_SELECT=6`; + - `XCURSOR_THEME=Adwaita`, `XCURSOR_SIZE=24`. +- **Session code** (ADR 0208 §4): + - in the `xinitrc` slot `normal`, the GSettings keys the portal serves (dark, the GTK and icon theme, + the cursor, the UI and monospace fonts), set at every session start. This replaces the + predecessor's `~/scripts/xdg-appearance`; + - in the `xresources` slot `normal`, `Xcursor.theme` and `Xcursor.size`. + +## What it owns + +| path | class | from | +|---|---|---| +| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) | +| `~/.config/gtk-4.0/settings.ini` | owned | the same file | +| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) | +| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) | +| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources | + +**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push, +and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it +the operator's (issue 168). + +## What it improves + +- **No package from the user repository.** Today's Qt style, `adwaita-dark` (`QT_STYLE_OVERRIDE` and + qt6ct's `Adwaita-Dark`), comes from the user repository's `adwaita-qt5`/`adwaita-qt6-git`, a + project that is no longer developed. The module uses Qt's own **Fusion** style with qt6ct's + **`darker`** palette, both shipped with Qt and qt6ct. **This is the one visible change:** Qt + programs keep a dark palette, drawn by Fusion instead of Adwaita-Qt. +- **One Qt tool.** `qt5ct` is gone (installed on the desktop only, with a configuration on both). + `QT_SELECT=6` and `qt6ct` cover the Qt 6 programs. A Qt 5 program gets Fusion through + `QT_STYLE_OVERRIDE`, but not the palette. +- **The fonts research 026/04 chose:** Inter 11 for GTK, Qt and GSettings' interface font, and + JetBrains Mono Nerd Font for Qt's fixed font and GSettings' monospace. Today these are Noto Sans 12, + Adwaita Sans 11 and nothing. +- **The cursor said everywhere**: GTK's settings, GSettings, `XCURSOR_*`, the X resources and the + default theme. Today only the resources and GSettings said it. +- **The portal answers secrets.** `portals.conf` routes `org.freedesktop.impl.portal.Secret` to + gnome-keyring. Today `adwaita_portal_check` shows no backend answering it: gtk does not implement + it, and gnome-keyring's backend names only GNOME. + +## Tools + +| tool | | what | +|---|---|---| +| `adwaita_appearance` | r/a | dark or light as each audience sees it (GSettings, the portal's own answer, the GTK files, Qt's style and palette, the theme words in the user manager). `mode` switches GSettings for the session, and the answer says what follows live (programs asking the portal) and what stays dark (GTK 3 under `GTK_THEME`, the declared files) | +| `adwaita_cursor` | r/a | the cursor in GSettings, the resources and the environment, and the cursor themes installed; set theme or size for new windows (GSettings, and the resources when a session runs) | +| `adwaita_icons` | r | icon themes installed (where, what each inherits, whether it has cursors), and the one GSettings, GTK and Qt use | +| `adwaita_portal_check` | r | the backends installed and what each implements, which `portals.conf` decides (the first that exists, in xdg-desktop-portal's order), the backend answering each interface, what runs on the bus, and the colour scheme the portal answers | + +Each reaches GSettings, the portal and the user manager on the account's bus. Only the cursor's X +resources need the session. From the user manager it reads only the theme's own words. + +**The appearance is a session's choice.** A persistent dark or light, for the files too, is a setting +and waits for issue 168. Until then the module's default is dark, and `mode` lasts until the next +login. + +## What it leaves found + +`~/.config/qt5ct/`, `~/.config/gtk-3.0/bookmarks` and everything else in those directories, +`~/scripts/xdg-appearance`, and the user repository's `adwaita-qt*` packages. + +## The one-off migration (ADR 0182) + +**Once `adwaita` is assigned:** + +1. In `~/.xinitrc`, the theme exports and `~/scripts/xdg-appearance || true` go (see `xorg`'s list). +2. Delete `~/scripts/xdg-appearance`. +3. In `~/.Xresources`, delete the two `Xcursor` lines. +4. Delete `~/.config/qt5ct/`. +5. Remove the user repository's packages: `sudo pacman -Rns adwaita-qt5-git adwaita-qt6-git` + (laptop), `sudo pacman -Rns adwaita-qt5 adwaita-qt6-git adwaita-dark qt5ct` (desktop). Check first + that nothing else needs them (`pacman -Qi`). + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| packages | none (all present) | the same | +| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same | +| `qt6ct.conf` | Fusion with the `darker` palette, Inter and JetBrains Mono, instead of Adwaita-Dark with Noto Sans | the same | +| `~/.icons/default/index.theme` | new | new | +| environment | `QT_STYLE_OVERRIDE` becomes `Fusion`; `XCURSOR_*` added; the rest as `~/.xinitrc` exported them | the same | +| running programs | **nothing**: settings are read at a program's start, and GSettings is set at the next login | the same | +| next login | GSettings' fonts become Inter and JetBrains Mono. A secret request through the portal finds gnome-keyring | the same | + +## Blockers + +- **`fonts` first**, for Inter and JetBrains Mono. Without them, GTK and Qt fall back to the nearest + installed face. +- **Light is a session's choice only**, until settings (issue 168). diff --git a/modules/adwaita/cmd/adwaita-tools/adwaita_test.go b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go new file mode 100644 index 0000000..fc1b326 --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "adwaita/internal/desktop" +) + +type manifest struct { + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Tools []string `json:"tools"` + Environment struct { + Variables map[string]string `json:"variables"` + } `json:"environment"` + Shell []struct { + For, Slot, Code string + } `json:"shell"` + Resources []map[string]any `json:"resources"` +} + +func readManifest(t *testing.T) manifest { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestItContributesTheThemesWordsAndClaimsNoSeat(t *testing.T) { + m := readManifest(t) + if m.Claims != nil { + t.Fatal("a theme is not a seat: several coexist") + } + want := map[string]string{"GTK_THEME": "Adwaita:dark", "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", + "QT_QPA_PLATFORMTHEME": "qt6ct", "QT_STYLE_OVERRIDE": "Fusion", "QT_SELECT": "6", "XCURSOR_THEME": "Adwaita", "XCURSOR_SIZE": "24"} + if len(m.Environment.Variables) != len(want) { + t.Fatalf("%v", m.Environment.Variables) + } + for k, v := range want { + if m.Environment.Variables[k] != v { + t.Errorf("%s=%q", k, m.Environment.Variables[k]) + } + } + served := map[string]bool{} + for _, tool := range tools(adwaita{}) { + served[tool.Name] = true + } + if len(served) != len(m.Tools) { + t.Fatalf("%v %v", served, m.Tools) + } + for _, n := range m.Tools { + if !served[n] { + t.Errorf("%s", n) + } + } +} + +func TestTheSessionLinesSetGSettingsAndTheResourcesTheCursor(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 2 || m.Shell[0].For != "xresources" || m.Shell[1].For != "xinitrc" || m.Shell[0].Slot != "normal" || m.Shell[1].Slot != "normal" { + t.Fatalf("%+v", m.Shell) + } + if m.Shell[0].Code != "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" { + t.Fatalf("%q", m.Shell[0].Code) + } + x := m.Shell[1].Code + for _, want := range []string{"color-scheme 'prefer-dark'", "gtk-theme 'Adwaita'", "icon-theme 'Adwaita'", "cursor-theme 'Adwaita'", "font-name 'Inter 11'", "monospace-font-name 'JetBrainsMono Nerd Font 11'"} { + if !strings.Contains(x, want) { + t.Errorf("lacks %s", want) + } + } + for _, l := range strings.Split(strings.TrimSpace(x), "\n") { + if !strings.HasPrefix(l, "#") && !strings.HasSuffix(l, "|| true") { + t.Errorf("a session line that can stop the session's start: %q", l) + } + } +} + +func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { + m := readManifest(t) + sources := map[string]string{"gtk3": "gtk-settings.ini", "gtk4": "gtk-settings.ini", "qt6ct": "qt6ct.conf", "portals": "portals.conf", "cursor": "cursor-index.theme"} + pkgs := []string{} + for _, r := range m.Resources { + id := r["id"].(string) + if r["type"] == "package" { + pkgs = append(pkgs, r["package"].(string)) + continue + } + raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id])) + if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" { + t.Errorf("%s is not config/%s, whole and the account's", id, sources[id]) + } + if strings.Contains(r["path"].(string), "qt5ct") { + t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate") + } + } + if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" { + t.Fatalf("%v", pkgs) + } + qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf")) + if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") { + t.Fatalf("%v", qt) + } + if _, err := os.Stat("/usr/share/qt6ct/colors"); err == nil { + if _, err := os.Stat(qt["Appearance"]["color_scheme_path"]); err != nil { + t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"]) + } + } + gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"] + if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" { + t.Fatalf("%v", gtk) + } +} + +func TestKeyFilesAreReadWithTheirComments(t *testing.T) { + ini := ParseINI("# c\n[A]\nk = v\n; also a comment\n[B]\nx=1=2\n") + if ini["A"]["k"] != "v" || ini["B"]["x"] != "1=2" || len(ini) != 2 { + t.Fatalf("%v", ini) + } +} + +func TestThePortalsAnswerIsResolvedAsXdgDesktopPortalDoes(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "gtk.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.gtk\nInterfaces=org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Settings;\nUseIn=gnome\n"), 0o644) + os.WriteFile(filepath.Join(dir, "gnome-keyring.portal"), []byte("[portal]\nDBusName=org.freedesktop.secrets\nInterfaces=org.freedesktop.impl.portal.Secret;\nUseIn=gnome\n"), 0o644) + os.WriteFile(filepath.Join(dir, "kde.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.kde\nInterfaces=org.freedesktop.impl.portal.FileChooser;\nUseIn=KDE\n"), 0o644) + b := Backends([]string{dir}) + if len(b) != 3 || b[0].Name != "gnome-keyring" || len(b[1].Interfaces) != 2 { + t.Fatalf("%+v", b) + } + got := Resolve(b, map[string]string{"default": "gtk", "org.freedesktop.impl.portal.Secret": "gnome-keyring"}, []string{"i3"}) + if got["org.freedesktop.impl.portal.FileChooser"] != "gtk" || got["org.freedesktop.impl.portal.Secret"] != "gnome-keyring" || got["org.freedesktop.impl.portal.Settings"] != "gtk" { + t.Fatalf("%v", got) + } + got = Resolve(b, map[string]string{"default": "gtk"}, []string{"i3"}) + if got["org.freedesktop.impl.portal.Secret"] != "(none)" { + t.Fatalf("gtk does not implement secrets: %v", got) + } + got = Resolve(b, map[string]string{"default": "none;gtk"}, nil) + if got["org.freedesktop.impl.portal.FileChooser"] != "(none)" { + t.Fatalf("none stops the list: %v", got) + } + got = Resolve(b, nil, []string{"KDE"}) + if got["org.freedesktop.impl.portal.FileChooser"] != "kde" || got["org.freedesktop.impl.portal.Settings"] != "(none)" { + t.Fatalf("with no configuration, UseIn decides: %v", got) + } + c := PortalConfigs("/h", []string{"i3", "GNOME"}) + if c[0] != "/h/.config/xdg-desktop-portal/i3-portals.conf" || c[1] != "/h/.config/xdg-desktop-portal/gnome-portals.conf" || c[2] != "/h/.config/xdg-desktop-portal/portals.conf" { + t.Fatalf("%v", c[:3]) + } +} + +func TestThemesAreFoundOnceEachWithCursorsAndIcons(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + os.MkdirAll(filepath.Join(a, "Adwaita", "cursors"), 0o755) + os.MkdirAll(filepath.Join(b, "Adwaita"), 0o755) + os.WriteFile(filepath.Join(b, "Adwaita", "index.theme"), []byte("[Icon Theme]\nName=Adwaita\nInherits=hicolor\nDirectories=16x16\n"), 0o644) + os.MkdirAll(filepath.Join(b, "hicolor"), 0o755) + os.WriteFile(filepath.Join(b, "hicolor", "index.theme"), []byte("[Icon Theme]\nName=Hicolor\nDirectories=16x16\n"), 0o644) + os.MkdirAll(filepath.Join(b, "empty"), 0o755) + got := Themes([]string{a, b}) + if len(got) != 2 || got[0].Name != "Adwaita" || !got[0].Cursors || got[0].Icons || got[0].Dir != filepath.Join(a, "Adwaita") || got[1].Name != "hicolor" { + t.Fatalf("the first directory's Adwaita hides the second's: %+v", got) + } +} + +type fake struct { + ran []string + get map[string]string +} + +func (f *fake) desk() desktop.Desk { + return desktop.Desk{ + Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} }, + Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result { + line := strings.Join(append([]string{name}, args...), " ") + f.ran = append(f.ran, line) + switch { + case name == "gsettings" && args[0] == "get": + return desktop.Result{Stdout: "'" + f.get[args[2]] + "'\n"} + case name == "gsettings" && args[0] == "set": + f.get[args[2]] = args[3] + case name == "systemctl": + return desktop.Result{Stdout: "GTK_THEME=Adwaita:dark\nNPM_TOKEN=secret\nXDG_CURRENT_DESKTOP=i3\n"} + case name == "busctl" && args[1] == "call": + return desktop.Result{Stdout: "v u 1\n"} + } + return desktop.Result{} + }, + } +} + +func TestAppearanceSwitchesGSettingsAndSaysWhatFollowsAndWhatStays(t *testing.T) { + f := &fake{get: map[string]string{"color-scheme": "prefer-dark", "gtk-theme": "Adwaita"}} + a := adwaita{d: f.desk(), home: t.TempDir()} + got, err := a.appearance(context.Background(), desktop.Args{"mode": "light"}) + if err != nil { + t.Fatal(err) + } + j := asJSON(got) + if f.get["color-scheme"] != "prefer-light" || !strings.Contains(j, `"switched":"light"`) || !strings.Contains(j, "GTK_THEME=Adwaita:dark") || !strings.Contains(j, `"lasts"`) { + t.Fatalf("%s", j) + } + if strings.Contains(j, "secret") || strings.Contains(j, "NPM_TOKEN") { + t.Fatal("only the theme's words are read back from the user manager") + } + if _, err := a.appearance(context.Background(), desktop.Args{"mode": "blue"}); err == nil { + t.Fatal("mode is dark or light") + } + got, _ = a.appearance(context.Background(), desktop.Args{}) + if strings.Contains(asJSON(got), "switched") { + t.Fatal("reading changes nothing") + } +} + +func TestACursorThemeMustBeInstalledAndWithoutASessionOnlyGSettingsChanges(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "Adwaita", "cursors"), 0o755) + f := &fake{get: map[string]string{}} + a := adwaita{d: f.desk(), home: t.TempDir(), iconDirs: []string{dir}} + if _, err := a.cursor(context.Background(), desktop.Args{"theme": "Bibata"}); err == nil { + t.Fatal("a theme that is not installed") + } + got, err := a.cursor(context.Background(), desktop.Args{"theme": "Adwaita", "size": float64(32)}) + if err != nil { + t.Fatal(err) + } + if f.get["cursor-theme"] != "Adwaita" || f.get["cursor-size"] != "32" || !strings.Contains(asJSON(got), "no graphical session") { + t.Fatalf("%v %s", f.get, asJSON(got)) + } +} + +func asJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/modules/adwaita/cmd/adwaita-tools/main.go b/modules/adwaita/cmd/adwaita-tools/main.go new file mode 100644 index 0000000..269dc1c --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/main.go @@ -0,0 +1,84 @@ +// adwaita's tools (novox/hq ADR 0208, research 026/05): appearance (dark or light for GTK, Qt and the +// portal at once), cursor, icons and portal-check. The predecessor's appearance script is folded into +// the first, and into the module's session line. +// +// None needs the display except setting the cursor's X resources: GSettings, the portal and the user +// manager are reached on the account's own bus, which exists whenever the operator's user manager +// runs. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "adwaita/internal/desktop" +) + +func main() { + home := desktop.Home() + a := adwaita{ + d: desktop.Machine("i3", "sway"), home: home, + iconDirs: []string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons"), "/usr/local/share/icons", "/usr/share/icons"}, + portalDirs: []string{"/usr/share/xdg-desktop-portal/portals"}, + uid: os.Getuid(), + } + if err := stdio.Serve("", tools(a)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + return run(ctx, desktop.Args(args)) + } +} + +func tools(a adwaita) []stdio.Tool { + return []stdio.Tool{ + { + Name: "adwaita_appearance", + Description: "Dark or light, as each audience sees it now: GSettings (which the portal serves to " + + "Electron, Firefox and flatpaks), the portal's own answer, the GTK settings files, Qt's palette " + + "and the theme words in the user manager's environment. With mode, switch GSettings for the " + + "running session and answer which audiences follow live and which keep the module's dark " + + "default until it is a setting.", + Input: desktop.Schema(map[string]any{"mode": desktop.Enum("switch to (optional)", "dark", "light")}), + Run: call(a.appearance), + }, + { + Name: "adwaita_cursor", + Description: "The cursor theme and size in force (GSettings, the X resources, XCURSOR_* in the user " + + "manager) and the cursor themes installed. With theme and/or size, set them for windows opened " + + "from now on; the module's defaults return at the next login.", + Input: desktop.Schema(map[string]any{ + "theme": desktop.Str("an installed cursor theme"), + "size": desktop.Int("pixels, 8 to 256"), + }), + Run: call(a.cursor), + }, + { + Name: "adwaita_icons", + Description: "The icon themes installed (name, where, what each inherits, whether it carries cursors) " + + "and the one GTK and Qt are set to use.", + Input: desktop.Schema(map[string]any{}), + Run: call(a.icons), + }, + { + Name: "adwaita_portal_check", + Description: "Which xdg-desktop-portal backend answers which interface for this desktop: the backends " + + "installed and what they implement, the portals.conf that decides (and which one won), the " + + "resulting backend per interface, whether the portal and each backend are running on the " + + "account's bus, and the colour scheme the portal answers.", + Input: desktop.Schema(map[string]any{}), + Run: call(a.portalCheck), + }, + } +} diff --git a/modules/adwaita/cmd/adwaita-tools/tools.go b/modules/adwaita/cmd/adwaita-tools/tools.go new file mode 100644 index 0000000..f9d29c4 --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/tools.go @@ -0,0 +1,434 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "adwaita/internal/desktop" +) + +type adwaita struct { + d desktop.Desk + home string + iconDirs []string + portalDirs []string + uid int +} + +const iface = "org.gnome.desktop.interface" + +// ParseINI reads a key file (GTK's settings.ini, qt6ct.conf, a .portal, index.theme): sections of +// key=value, `#` and `;` comments. +func ParseINI(text string) map[string]map[string]string { + out := map[string]map[string]string{} + section := "" + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + l := strings.TrimSpace(sc.Text()) + if l == "" || strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") { + continue + } + if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { + section = l[1 : len(l)-1] + continue + } + if k, v, ok := strings.Cut(l, "="); ok { + if out[section] == nil { + out[section] = map[string]string{} + } + out[section][strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return out +} + +func readINI(path string) map[string]map[string]string { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + return ParseINI(string(b)) +} + +// gsetting is one GSettings value, unquoted. +func (a adwaita) gsetting(ctx context.Context, schema, key string) (string, error) { + r := a.d.AsUser(ctx, "gsettings", "get", schema, key) + if !r.OK() { + return "", r.Err() + } + return strings.Trim(strings.TrimSpace(r.Stdout), "'"), nil +} + +// themeWords are the words of the user manager's environment the theme is about; nothing else of it +// is read back. +var themeWords = []string{"GTK_THEME", "GTK2_RC_FILES", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SELECT", + "XCURSOR_THEME", "XCURSOR_SIZE", "XDG_CURRENT_DESKTOP"} + +func (a adwaita) userEnvironment(ctx context.Context) map[string]string { + r := a.d.AsUser(ctx, "systemctl", "--user", "show-environment") + all := desktop.ParseProperties(r.Stdout) + out := map[string]string{} + for _, w := range themeWords { + if v, ok := all[w]; ok { + out[w] = v + } + } + return out +} + +var portalScheme = regexp.MustCompile(`^v u (\d)`) + +// portalColourScheme asks the portal what it tells applications: 0 no preference, 1 dark, 2 light. +func (a adwaita) portalColourScheme(ctx context.Context) string { + r := a.d.AsUser(ctx, "busctl", "--user", "call", "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", "ReadOne", "ss", "org.freedesktop.appearance", "color-scheme") + m := portalScheme.FindStringSubmatch(strings.TrimSpace(r.Stdout)) + if !r.OK() || m == nil { + return "unanswered" + } + return map[string]string{"0": "no-preference", "1": "dark", "2": "light"}[m[1]] +} + +func (a adwaita) appearance(ctx context.Context, args desktop.Args) (any, error) { + mode := args.Opt("mode", "") + if mode != "" && mode != "dark" && mode != "light" { + return nil, fmt.Errorf("mode is dark or light") + } + if mode != "" { + scheme := map[string]string{"dark": "prefer-dark", "light": "prefer-light"}[mode] + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "color-scheme", scheme); !r.OK() { + return nil, r.Err() + } + } + scheme, err := a.gsetting(ctx, iface, "color-scheme") + if err != nil { + return nil, fmt.Errorf("GSettings does not answer on the account's bus: %w", err) + } + gtkTheme, _ := a.gsetting(ctx, iface, "gtk-theme") + gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] + gtk4 := readINI(filepath.Join(a.home, ".config", "gtk-4.0", "settings.ini"))["Settings"] + qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] + env := a.userEnvironment(ctx) + answer := map[string]any{ + "gsettings": map[string]string{"color-scheme": scheme, "gtk-theme": gtkTheme}, + "portal": a.portalColourScheme(ctx), + "gtk3": map[string]string{"theme": gtk3["gtk-theme-name"], "prefer-dark": gtk3["gtk-application-prefer-dark-theme"]}, + "gtk4": map[string]string{"theme": gtk4["gtk-theme-name"], "prefer-dark": gtk4["gtk-application-prefer-dark-theme"]}, + "qt": map[string]string{"style": qt["style"], "palette": filepath.Base(qt["color_scheme_path"])}, + "environment": env, + } + if mode != "" { + var stays []string + if strings.HasSuffix(env["GTK_THEME"], ":dark") && mode == "light" { + stays = append(stays, "GTK 3 programs: GTK_THEME="+env["GTK_THEME"]+" in the environment pins them dark") + } + if mode == "light" { + stays = append(stays, "the GTK settings files and Qt's palette, which the module declares dark") + } + answer["switched"] = mode + answer["follows_live"] = "programs asking the portal: Electron, Chromium, Firefox, libadwaita and flatpaks" + if len(stays) > 0 { + answer["stays"] = stays + } + answer["lasts"] = "until the next login, which sets the module's default (dark) again; a persistent choice waits for settings (hq issue 168)" + } + return answer, nil +} + +// Theme is one installed icon or cursor theme. +type Theme struct { + Name string `json:"name"` + Dir string `json:"dir"` + Title string `json:"title,omitempty"` + Inherits []string `json:"inherits,omitempty"` + Cursors bool `json:"cursors"` + Icons bool `json:"icons"` +} + +// Themes lists the themes in dirs; a name found earlier hides the same name later, as lookups do. +func Themes(dirs []string) []Theme { + seen := map[string]bool{} + var out []Theme + for _, d := range dirs { + entries, _ := os.ReadDir(d) + for _, e := range entries { + if !e.IsDir() && e.Type()&os.ModeSymlink == 0 || seen[e.Name()] { + continue + } + dir := filepath.Join(d, e.Name()) + t := Theme{Name: e.Name(), Dir: dir} + if info, err := os.Stat(filepath.Join(dir, "cursors")); err == nil && info.IsDir() { + t.Cursors = true + } + if ini := readINI(filepath.Join(dir, "index.theme")); ini != nil { + th := ini["Icon Theme"] + t.Title = th["Name"] + if th["Directories"] != "" { + t.Icons = true + } + for _, i := range strings.Split(th["Inherits"], ",") { + if i = strings.TrimSpace(i); i != "" { + t.Inherits = append(t.Inherits, i) + } + } + } + if !t.Cursors && !t.Icons && t.Title == "" { + continue + } + seen[e.Name()] = true + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +var cursorName = regexp.MustCompile(`^[A-Za-z0-9._ -]{1,64}$`) + +func (a adwaita) cursor(ctx context.Context, args desktop.Args) (any, error) { + theme := args.Opt("theme", "") + size, err := args.Whole("size", 0, 8, 256) + if err != nil { + return nil, err + } + var cursors []string + installed := map[string]bool{} + for _, t := range Themes(a.iconDirs) { + if t.Cursors { + cursors = append(cursors, t.Name) + installed[t.Name] = true + } + } + changed := theme != "" || size != 0 + if theme != "" && (!cursorName.MatchString(theme) || !installed[theme]) { + return nil, fmt.Errorf("%q is not an installed cursor theme; installed: %s", theme, strings.Join(cursors, ", ")) + } + var resources []string + if theme != "" { + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-theme", theme); !r.OK() { + return nil, r.Err() + } + resources = append(resources, "Xcursor.theme: "+theme) + } + if size != 0 { + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-size", strconv.Itoa(size)); !r.OK() { + return nil, r.Err() + } + resources = append(resources, "Xcursor.size: "+strconv.Itoa(size)) + } + answer := map[string]any{"installed": cursors} + gTheme, _ := a.gsetting(ctx, iface, "cursor-theme") + gSize, _ := a.gsetting(ctx, iface, "cursor-size") + answer["gsettings"] = map[string]string{"cursor-theme": gTheme, "cursor-size": gSize} + env := a.userEnvironment(ctx) + answer["environment"] = map[string]string{"XCURSOR_THEME": env["XCURSOR_THEME"], "XCURSOR_SIZE": env["XCURSOR_SIZE"]} + if s, err := a.d.Find(); err == nil { + senv := s.Env(a.d.Base) + if len(resources) > 0 { + if r := a.d.Run(ctx, senv, []byte(strings.Join(resources, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() { + return nil, r.Err() + } + } + q := a.d.Run(ctx, senv, nil, "xrdb", "-query") + x := map[string]string{} + for _, l := range strings.Split(q.Stdout, "\n") { + if k, v, ok := strings.Cut(l, ":"); ok && strings.HasPrefix(k, "Xcursor.") { + x[k] = strings.TrimSpace(v) + } + } + answer["x_resources"] = x + } else { + answer["x_resources"] = nil + if changed { + answer["note"] = "no graphical session: GSettings changed, the X resources not" + } + } + if changed { + answer["lasts"] = "for windows opened from now on, until the next login" + } + return answer, nil +} + +func (a adwaita) icons(ctx context.Context, args desktop.Args) (any, error) { + var themes []Theme + for _, t := range Themes(a.iconDirs) { + if t.Icons { + themes = append(themes, t) + } + } + gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] + qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] + g, _ := a.gsetting(ctx, iface, "icon-theme") + return map[string]any{ + "installed": themes, + "in_use": map[string]string{"gsettings": g, "gtk": gtk3["gtk-icon-theme-name"], "qt": qt["icon_theme"]}, + }, nil +} + +// Backend is one installed portal backend. +type Backend struct { + Name string `json:"name"` + DBusName string `json:"dbus_name"` + Interfaces []string `json:"interfaces"` + UseIn []string `json:"use_in,omitempty"` + Running bool `json:"running"` +} + +func splitList(v string) []string { + var out []string + for _, x := range strings.Split(v, ";") { + if x = strings.TrimSpace(x); x != "" { + out = append(out, x) + } + } + return out +} + +// Backends reads the installed `.portal` files. +func Backends(dirs []string) []Backend { + var out []Backend + for _, d := range dirs { + files, _ := filepath.Glob(filepath.Join(d, "*.portal")) + sort.Strings(files) + for _, f := range files { + p := readINI(f)["portal"] + out = append(out, Backend{Name: strings.TrimSuffix(filepath.Base(f), ".portal"), DBusName: p["DBusName"], + Interfaces: splitList(p["Interfaces"]), UseIn: splitList(p["UseIn"])}) + } + } + return out +} + +// PortalConfigs are the files xdg-desktop-portal looks for, in its order (portals.conf(5)): for each +// directory, `-portals.conf` for each of the desktops named, then `portals.conf`. The first +// that exists decides everything. +func PortalConfigs(home string, desktops []string) []string { + dirs := []string{ + filepath.Join(home, ".config", "xdg-desktop-portal"), "/etc/xdg/xdg-desktop-portal", "/etc/xdg-desktop-portal", + filepath.Join(home, ".local", "share", "xdg-desktop-portal"), "/usr/local/share/xdg-desktop-portal", "/usr/share/xdg-desktop-portal", + } + var out []string + for _, d := range dirs { + for _, desk := range desktops { + out = append(out, filepath.Join(d, strings.ToLower(desk)+"-portals.conf")) + } + out = append(out, filepath.Join(d, "portals.conf")) + } + return out +} + +// Resolve says which backend answers each interface the backends implement, given the deciding +// file's [preferred] section: an interface's own key first, else `default`; each a list of backend +// names, the first one that implements the interface wins; `none` answers nothing, `*` any. +// With no file, a backend whose UseIn names the desktop answers. +func Resolve(backends []Backend, preferred map[string]string, desktops []string) map[string]string { + out := map[string]string{} + implements := func(b Backend, i string) bool { + for _, x := range b.Interfaces { + if x == i { + return true + } + } + return false + } + var all []string + seen := map[string]bool{} + for _, b := range backends { + for _, i := range b.Interfaces { + if !seen[i] { + seen[i] = true + all = append(all, i) + } + } + } + sort.Strings(all) + for _, i := range all { + var want []string + if preferred != nil { + if v, ok := preferred[i]; ok { + want = splitList(v) + } else { + want = splitList(preferred["default"]) + } + } else { + for _, b := range backends { + for _, u := range b.UseIn { + for _, d := range desktops { + if strings.EqualFold(u, d) { + want = append(want, b.Name) + } + } + } + } + } + out[i] = "(none)" + pick: + for _, w := range want { + if w == "none" { + break + } + for _, b := range backends { + if (w == "*" || w == b.Name) && implements(b, i) { + out[i] = b.Name + break pick + } + } + } + } + return out +} + +func (a adwaita) portalCheck(ctx context.Context, args desktop.Args) (any, error) { + env := a.userEnvironment(ctx) + desktops := splitColon(env["XDG_CURRENT_DESKTOP"]) + backends := Backends(a.portalDirs) + names := map[string]bool{} + if r := a.d.AsUser(ctx, "busctl", "--user", "list", "--no-legend", "--no-pager"); r.OK() { + for _, l := range strings.Split(r.Stdout, "\n") { + if f := strings.Fields(l); len(f) > 1 && f[1] != "-" { + names[f[0]] = true + } + } + } + for i := range backends { + backends[i].Running = names[backends[i].DBusName] + } + var decided string + var preferred map[string]string + looked := PortalConfigs(a.home, desktops) + for _, f := range looked { + if ini := readINI(f); ini != nil { + decided, preferred = f, ini["preferred"] + if preferred == nil { + preferred = map[string]string{} + } + break + } + } + return map[string]any{ + "desktop": env["XDG_CURRENT_DESKTOP"], + "portal": map[string]bool{"running": names["org.freedesktop.portal.Desktop"]}, + "backends": backends, + "decided_by": decided, + "preferred": preferred, + "answers": Resolve(backends, preferred, desktops), + "colour_scheme": a.portalColourScheme(ctx), + }, nil +} + +func splitColon(v string) []string { + var out []string + for _, x := range strings.Split(v, ":") { + if x = strings.TrimSpace(x); x != "" { + out = append(out, x) + } + } + return out +} diff --git a/modules/adwaita/config/cursor-index.theme b/modules/adwaita/config/cursor-index.theme new file mode 100644 index 0000000..d848240 --- /dev/null +++ b/modules/adwaita/config/cursor-index.theme @@ -0,0 +1,5 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that +# read neither XCURSOR_THEME nor the X resources. +[Icon Theme] +Name=Default +Inherits=Adwaita diff --git a/modules/adwaita/config/gtk-settings.ini b/modules/adwaita/config/gtk-settings.ini new file mode 100644 index 0000000..a535ba4 --- /dev/null +++ b/modules/adwaita/config/gtk-settings.ini @@ -0,0 +1,9 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at +# every push; adwaita_appearance switches dark and light for the running session. +[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-cursor-theme-name=Adwaita +gtk-cursor-theme-size=24 +gtk-font-name=Inter 11 +gtk-application-prefer-dark-theme=1 diff --git a/modules/adwaita/config/portals.conf b/modules/adwaita/config/portals.conf new file mode 100644 index 0000000..d2e813f --- /dev/null +++ b/modules/adwaita/config/portals.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push. +# +# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with +# no preference it uses whichever backend happens to be installed: fine while gtk is the only one, +# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves +# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets. +[preferred] +default=gtk +# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers +# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop. +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/modules/adwaita/config/qt6ct.conf b/modules/adwaita/config/qt6ct.conf new file mode 100644 index 0000000..7f396d2 --- /dev/null +++ b/modules/adwaita/config/qt6ct.conf @@ -0,0 +1,29 @@ +[Appearance] +color_scheme_path=/usr/share/qt6ct/colors/darker.conf +custom_palette=true +icon_theme=Adwaita +standard_dialogs=default +style=Fusion + +[Fonts] +fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0" +general="Inter,11,-1,5,50,0,0,0,0,0" + +[Interface] +activate_item_on_single_click=1 +buttonbox_layout=0 +cursor_flash_time=1000 +dialog_buttons_have_icons=1 +double_click_interval=400 +gui_effects=@Invalid() +keyboard_scheme=2 +menus_have_icons=true +show_shortcuts_in_context_menus=true +stylesheets=@Invalid() +toolbutton_style=4 +underline_shortcut=1 +wheel_scroll_lines=3 + +[Troubleshooting] +force_raster_widgets=1 +ignored_applications=@Invalid() diff --git a/modules/adwaita/go.mod b/modules/adwaita/go.mod new file mode 100644 index 0000000..ed78144 --- /dev/null +++ b/modules/adwaita/go.mod @@ -0,0 +1,5 @@ +module adwaita + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/adwaita/go.sum b/modules/adwaita/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/adwaita/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/adwaita/internal/desktop/args.go b/modules/adwaita/internal/desktop/args.go new file mode 100644 index 0000000..d6be351 --- /dev/null +++ b/modules/adwaita/internal/desktop/args.go @@ -0,0 +1,160 @@ +package desktop + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans. +type Args map[string]any + +// Text is a required string, trimmed. +func (a Args) Text(name string) (string, error) { + v, ok := a[name].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is required, as text", name) + } + return strings.TrimSpace(v), nil +} + +// Opt is an optional string, trimmed, or def. +func (a Args) Opt(name, def string) string { + if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return def +} + +// Has is whether the caller gave the argument at all. +func (a Args) Has(name string) bool { + v, ok := a[name] + return ok && v != nil +} + +// Bool is an optional boolean: its value, and whether it was given. +func (a Args) Bool(name string) (bool, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return false, false, nil + } + b, isBool := v.(bool) + if !isBool { + return false, false, fmt.Errorf("%s is true or false", name) + } + return b, true, nil +} + +// Number is an optional number: its value, and whether it was given. +func (a Args) Number(name string) (float64, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return 0, false, nil + } + f, isNum := v.(float64) + if !isNum || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false, fmt.Errorf("%s is a number", name) + } + return f, true, nil +} + +// Whole is an optional whole number within [lo, hi], or def. +func (a Args) Whole(name string, def, lo, hi int) (int, error) { + f, given, err := a.Number(name) + if err != nil { + return 0, err + } + if !given { + return def, nil + } + if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi) + } + return int(f), nil +} + +// OneOf is an optional string that must be one of choices, or def. +func (a Args) OneOf(name, def string, choices ...string) (string, error) { + v := a.Opt(name, def) + for _, c := range choices { + if v == c { + return v, nil + } + } + return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", ")) +} + +// Strings is an optional list of strings. +func (a Args) Strings(name string) ([]string, error) { + v, ok := a[name] + if !ok || v == nil { + return nil, nil + } + list, isList := v.([]any) + if !isList { + return nil, fmt.Errorf("%s is a list of text", name) + } + out := make([]string, 0, len(list)) + for _, x := range list { + s, isText := x.(string) + if !isText { + return nil, fmt.Errorf("%s is a list of text", name) + } + out = append(out, s) + } + return out, nil +} + +// Home is the operator account's home: the runtime's word for it, else this process's. +func Home() string { + if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" { + return h + } + if h, err := os.UserHomeDir(); err == nil { + return h + } + return "/" +} + +// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path +// that leaves the home through `..` is refused, so a tool that writes never writes outside it. +func InHome(path string) (string, error) { + home := Home() + switch { + case path == "~": + path = home + case strings.HasPrefix(path, "~/"): + path = filepath.Join(home, path[2:]) + case !filepath.IsAbs(path): + path = filepath.Join(home, path) + } + path = filepath.Clean(path) + if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) { + return "", fmt.Errorf("%s is outside the account's home", path) + } + return path, nil +} + +// Schema builds a tool's input schema from property descriptions; required names those that must +// be given. A property is a string unless its description object says otherwise. +func Schema(props map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// Str, Num, Flag, List and Enum describe one property. +func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } +func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} } +func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} } +func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } +func List(desc string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} +} +func Enum(desc string, values ...string) map[string]any { + return map[string]any{"type": "string", "enum": values, "description": desc} +} diff --git a/modules/adwaita/internal/desktop/copies_test.go b/modules/adwaita/internal/desktop/copies_test.go new file mode 100644 index 0000000..d6bc1b3 --- /dev/null +++ b/modules/adwaita/internal/desktop/copies_test.go @@ -0,0 +1,42 @@ +package desktop + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// The desktop modules that carry this package. Each builds alone, so each has its own copy; this +// test, itself one of the copied files, holds them to one text wherever the siblings are present. +var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"} + +func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) { + mine, err := filepath.Glob("*.go") + if err != nil || len(mine) == 0 { + t.Fatal("no files of this package found", err) + } + compared := 0 + for _, module := range carriers { + dir := filepath.Join("..", "..", "..", module, "internal", "desktop") + if _, err := os.Stat(dir); err != nil { + continue + } + theirs, _ := filepath.Glob(filepath.Join(dir, "*.go")) + if len(theirs) != len(mine) { + t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine)) + continue + } + for _, f := range mine { + a, _ := os.ReadFile(f) + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(a, b) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/adwaita/internal/desktop/run.go b/modules/adwaita/internal/desktop/run.go new file mode 100644 index 0000000..cb9898c --- /dev/null +++ b/modules/adwaita/internal/desktop/run.go @@ -0,0 +1,232 @@ +package desktop + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that +// fits in a tool's reply. +const ( + DefaultTimeout = 10 * time.Second + MostOutput = 256 << 10 +) + +// Result is what one command did. +type Result struct { + Command []string `json:"command"` + Code int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` +} + +// OK is whether the command ran and exited 0. +func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut } + +// Err is the command's failure as an error naming it and what it said, or nil. +func (r Result) Err() error { + if r.OK() { + return nil + } + said := strings.TrimSpace(r.Stderr) + if said == "" { + said = strings.TrimSpace(r.Stdout) + } + if r.TimedOut { + return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " ")) + } + return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said) +} + +// Runner runs a command with an environment and answers what it did. Tools take one, so their +// tests replace the machine with a table of answers. +type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result + +// Exec is the machine's Runner: the command in its own process group, ended with everything it +// started at the deadline, each stream cut at MostOutput. +func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + } + res := Result{Command: append([]string{name}, args...)} + cmd := exec.Command(name, args...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + out, errb := &capped{}, &capped{} + cmd.Stdout, cmd.Stderr = out, errb + if err := cmd.Start(); err != nil { + res.Code = 127 + res.Stderr = err.Error() + return res + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-ctx.Done(): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + err = <-done + res.TimedOut = true + } + res.Stdout, res.Stderr = out.String(), errb.String() + res.Truncated = out.cut || errb.cut + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + res.Code = exit.ExitCode() + if res.Code < 0 { + res.Code = 128 + } + default: + res.Code = 1 + if res.Stderr == "" { + res.Stderr = err.Error() + } + } + return res +} + +// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an +// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write. +type capped struct { + buf bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.buf.Len(); room < len(p) { + if room > 0 { + c.buf.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.buf.Write(p) +} + +func (c *capped) String() string { return c.buf.String() } + +// Desk is what a desktop tool needs: how to find the session, and how to run a command. +type Desk struct { + Find func() (*Session, error) + Run Runner + // Base is the environment a command starts from, before the session's words. + Base []string +} + +// Machine is the real Desk, preferring the named processes as the session's. +func Machine(prefer ...string) Desk { + return Desk{ + Find: func() (*Session, error) { return Find(prefer...) }, + Run: Exec, + Base: os.Environ(), + } +} + +// InSession runs a command in the operator's session, or answers NoSession. +func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil +} + +// InSessionWith is InSession with standard input. +func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil +} + +// Plain runs a command with the base environment: for what needs no session. +func (d Desk) Plain(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, d.Base, nil, name, args...) +} + +// AsUser runs a command with the account's own runtime directory and bus, and no display. +func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...) +} + +// Launched is how a program was started in the session. +type Launched struct { + Unit string `json:"unit,omitempty"` + PID int `json:"pid,omitempty"` + How string `json:"how"` +} + +// Launch starts a program in the operator's session that outlives the call and the runtime. +// +// **Not as a child of this process.** The runtime is a system service; everything it starts is in +// its control group, and the service manager ends that group whenever the runtime restarts — which +// is every push that changes it. So the program is handed to the account's own service manager as a +// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the +// operator's user manager does. Without a user manager it is started detached as a last resort, and +// the answer says it will end with the runtime. +func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) { + if len(argv) == 0 { + return Launched{}, errors.New("nothing to launch") + } + env := s.Env(d.Base) + unit := "mesh-" + name + "-" + token() + args := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} { + if v := lookup(env, w); v != "" { + args = append(args, "--setenv="+w+"="+v) + } + } + args = append(args, "--") + args = append(args, argv...) + res := d.Run(ctx, env, nil, "systemd-run", args...) + if res.OK() { + return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil + } + if s.Bus != "" { + return Launched{}, res.Err() + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return Launched{}, err + } + pid := cmd.Process.Pid + go func() { _ = cmd.Wait() }() + return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil +} + +func lookup(env []string, name string) string { + for i := len(env) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(env[i], "="); ok && k == name { + return v + } + } + return "" +} + +func token() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/modules/adwaita/internal/desktop/session.go b/modules/adwaita/internal/desktop/session.go new file mode 100644 index 0000000..22d432e --- /dev/null +++ b/modules/adwaita/internal/desktop/session.go @@ -0,0 +1,445 @@ +// Package desktop is how a desktop module's tools act in the operator's graphical session +// (novox/hq ADR 0208, research 026/05). +// +// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a +// process of node-tools.service, started by the system's service manager as the operator account, +// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in +// was started elsewhere, by the login manager, and the only place its values are written down is +// the environment of the processes it started. So this package finds the session the way a person +// would: it looks at the operator account's own processes, takes the one that is plainly the +// session's (the window manager, or the oldest process carrying a display), confirms with logind +// that its session is a live local one, and checks that the display's socket is really there. +// +// **Only the session's own words are read.** A session's processes also carry whatever its start +// script exported — on the workstations that was a file of secrets — so the environment is filtered +// to a fixed list of names while it is read, and nothing else ever leaves /proc. +// +// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`, +// whenever it exists, because that is where the portal, the notifier and every user service +// listen. A session started on a private bus (a stale session, measured on one workstation) is +// reported as `session_bus` beside it, so the difference is visible rather than guessed at. +// +// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm, +// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change +// every copy together — the modules' tests compare them. +package desktop + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// SessionWords are the only environment words read from a session's process: the ones that say +// where the session is. Everything else in that environment is the operator's, and is never read. +var SessionWords = []string{ + "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", + "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR", + "I3SOCK", "SWAYSOCK", +} + +// Session is the operator's running graphical session, as a tool needs it. +type Session struct { + UID int `json:"uid"` + ID string `json:"session_id,omitempty"` + Type string `json:"type"` + Display string `json:"display,omitempty"` + WaylandDisplay string `json:"wayland_display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + RuntimeDir string `json:"runtime_dir"` + Bus string `json:"bus,omitempty"` + SessionBus string `json:"session_bus,omitempty"` + Desktop string `json:"desktop,omitempty"` + // FoundIn is the process whose environment named the session. + FoundIn Process `json:"found_in"` + // Active is logind's word on the session, when logind answered. + Active *bool `json:"active,omitempty"` + + words map[string]string +} + +// Process is one process the search looked at. +type Process struct { + PID int `json:"pid"` + Command string `json:"command"` + start uint64 +} + +// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool +// that returns it as its error still answers structured data. +type NoSession struct { + Reason string `json:"reason"` + Looked []string `json:"looked"` +} + +func (e *NoSession) Error() string { + b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked}) + return string(b) +} + +// IsNoSession is whether err says there is no session. +func IsNoSession(err error) bool { + var n *NoSession + return errors.As(err, &n) +} + +// Finder holds where the search looks, so a test can point it at a tree of its own. +type Finder struct { + Proc string // the process table: /proc + X11Sockets string // where X servers listen: /tmp/.X11-unix + RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user + UID int // whose session + // Prefer names the processes that are the session's own, best first: the session's holder. + Prefer []string + // Logind answers `loginctl show-session` for one id; nil skips the check. + Logind func(id string) (map[string]string, error) +} + +// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the +// operator account the runtime names (MESH_OPERATOR_ACCOUNT). +func DefaultFinder(prefer ...string) Finder { + uid := os.Getuid() + if uid == 0 { + if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" { + if u, err := user.Lookup(name); err == nil { + if n, err := strconv.Atoi(u.Uid); err == nil { + uid = n + } + } + } + } + return Finder{ + Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user", + UID: uid, Prefer: prefer, Logind: loginctl, + } +} + +// Find is the operator's session on this machine, preferring a process named in prefer. +func Find(prefer ...string) (*Session, error) { + return DefaultFinder(prefer...).Find() +} + +type candidate struct { + proc Process + words map[string]string + rank int + logind map[string]string +} + +// Find looks for the session. +func (f Finder) Find() (*Session, error) { + entries, err := os.ReadDir(f.Proc) + if err != nil { + return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}} + } + looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)} + var found []candidate + stale := 0 + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(f.Proc, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID { + continue + } + words := readWords(filepath.Join(dir, "environ")) + if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" { + continue + } + if !f.reachable(words) { + stale++ + continue + } + found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words}) + } + if len(found) == 0 { + reason := fmt.Sprintf("no process of uid %d carries a display", f.UID) + if stale > 0 { + reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID) + } + return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)} + } + + // logind's word on each session the candidates name, asked once per session. + asked := map[string]map[string]string{} + for i := range found { + id := found[i].words["XDG_SESSION_ID"] + if f.Logind == nil || id == "" { + found[i].rank = 1 + continue + } + props, done := asked[id] + if !done { + props, _ = f.Logind(id) + asked[id] = props + } + found[i].logind = props + switch { + case props == nil: + found[i].rank = 1 + case props["Remote"] == "yes": + found[i].rank = 3 + case props["Active"] == "yes" && props["State"] != "closing": + found[i].rank = 0 + case props["State"] == "closing": + found[i].rank = 3 + default: + found[i].rank = 2 + } + } + if f.Logind != nil { + looked = append(looked, "logind's sessions") + } + preferred := func(c candidate) int { + for i, p := range f.Prefer { + if c.proc.Command == p { + return i + } + } + return len(f.Prefer) + } + sort.SliceStable(found, func(i, j int) bool { + a, b := found[i], found[j] + if a.rank != b.rank { + return a.rank < b.rank + } + if pa, pb := preferred(a), preferred(b); pa != pb { + return pa < pb + } + if a.proc.start != b.proc.start { + return a.proc.start < b.proc.start + } + return a.proc.PID < b.proc.PID + }) + best := found[0] + if best.rank == 3 { + return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked} + } + return f.session(best), nil +} + +func (f Finder) session(c candidate) *Session { + w := c.words + s := &Session{ + UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"], + XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w, + } + s.Desktop = w["XDG_CURRENT_DESKTOP"] + if s.Desktop == "" { + s.Desktop = w["XDG_SESSION_DESKTOP"] + } + switch { + case w["XDG_SESSION_TYPE"] != "": + s.Type = w["XDG_SESSION_TYPE"] + case s.WaylandDisplay != "": + s.Type = "wayland" + default: + s.Type = "x11" + } + if s.RuntimeDir == "" { + s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + if isSocket(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus { + s.SessionBus = own + } + if c.logind != nil { + active := c.logind["Active"] == "yes" + s.Active = &active + } + return s +} + +// reachable is whether the display a process names is still served: the X server's socket, or the +// Wayland compositor's. A process outliving its session still carries the session's words. +func (f Finder) reachable(w map[string]string) bool { + if d := w["WAYLAND_DISPLAY"]; d != "" { + path := d + if !filepath.IsAbs(d) { + dir := w["XDG_RUNTIME_DIR"] + if dir == "" { + dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + path = filepath.Join(dir, d) + } + if isSocket(path) { + return true + } + } + n, ok := DisplayNumber(w["DISPLAY"]) + return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n))) +} + +// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on +// another host — an ssh session's forwarded one — is not the local session and answers false. +func DisplayNumber(display string) (int, bool) { + host, rest, ok := strings.Cut(display, ":") + if !ok || (host != "" && host != "unix") { + return 0, false + } + num, _, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(num) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// Word is one of the session's words as its process had it ("" when it had none). +func (s *Session) Word(name string) string { return s.words[name] } + +// Env is base with the session's words in place of whatever base said for them. +func (s *Session) Env(base []string) []string { + drop := map[string]bool{} + for _, w := range SessionWords { + drop[w] = true + } + out := make([]string, 0, len(base)+8) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if !drop[k] { + out = append(out, kv) + } + } + bus := s.Bus + if bus == "" { + bus = s.SessionBus + } + for _, kv := range [][2]string{ + {"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority}, + {"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus}, + {"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID}, + {"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]}, + {"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]}, + {"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]}, + } { + if kv[1] != "" { + out = append(out, kv[0]+"="+kv[1]) + } + } + return out +} + +// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the +// user manager or the session bus and needs no display — it works with no session at all. +func UserEnv(base []string, uid int) []string { + dir := filepath.Join("/run/user", strconv.Itoa(uid)) + out := make([]string, 0, len(base)+2) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" { + out = append(out, kv) + } + } + return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus")) +} + +// readWords reads a process's environment and keeps only SessionWords. +func readWords(path string) map[string]string { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + keep := map[string]bool{} + for _, w := range SessionWords { + keep[w] = true + } + out := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + k, v, ok := bytes.Cut(kv, []byte{'='}) + if ok && keep[string(k)] { + out[string(k)] = string(v) + } + } + return out +} + +func comm(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "comm")) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// startTime is field 22 of /proc//stat: when the process started, in clock ticks since boot. +// Read after the command's closing parenthesis, because the command may hold spaces. +func startTime(dir string) uint64 { + b, err := os.ReadFile(filepath.Join(dir, "stat")) + if err != nil { + return ^uint64(0) + } + i := bytes.LastIndexByte(b, ')') + if i < 0 { + return ^uint64(0) + } + fields := strings.Fields(string(b[i+1:])) + // fields[0] is the state, field 3 of the line; start time is field 22. + if len(fields) < 20 { + return ^uint64(0) + } + n, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return ^uint64(0) + } + return n +} + +func isSocket(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +// loginctl asks logind about one session, by its property lines. +func loginctl(id string) (map[string]string, error) { + cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class") + var out bytes.Buffer + cmd.Stdout = &out + done := make(chan error, 1) + if err := cmd.Start(); err != nil { + return nil, err + } + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + return nil, err + } + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + return nil, errors.New("loginctl did not answer in 3s") + } + return ParseProperties(out.String()), nil +} + +// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them. +func ParseProperties(text string) map[string]string { + out := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok { + out[k] = v + } + } + return out +} diff --git a/modules/adwaita/internal/desktop/session_test.go b/modules/adwaita/internal/desktop/session_test.go new file mode 100644 index 0000000..119c16d --- /dev/null +++ b/modules/adwaita/internal/desktop/session_test.go @@ -0,0 +1,255 @@ +package desktop + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A machine in a directory: a process table, the X servers' socket directory and a runtime base. +type fakeMachine struct { + t *testing.T + proc, x11, runtime string + uid int +} + +func newMachine(t *testing.T) *fakeMachine { + root, err := os.MkdirTemp("", "desk") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(root) }) + m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()} + for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + return m +} + +func (m *fakeMachine) socket(path string) { + l, err := net.Listen("unix", path) + if err != nil { + m.t.Fatal(err) + } + m.t.Cleanup(func() { l.Close() }) +} + +func (m *fakeMachine) process(pid int, comm string, start int, env ...string) { + dir := filepath.Join(m.proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + m.t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600) + os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + // pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime + // cutime cstime priority nice threads itrealvalue starttime ... + stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0" + os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644) +} + +func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder { + return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind} +} + +func active(id string) (map[string]string, error) { + return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil +} + +func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X1")) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "bus")) + m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1") + m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1", + "XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret") + m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate + + s, err := m.finder(active, "i3").Find() + if err != nil { + t.Fatal(err) + } + if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" { + t.Fatalf("session: %+v", s) + } + if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" { + t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus) + } + if s.Active == nil || !*s.Active { + t.Fatal("logind's word is carried") + } + env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n") + for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} { + if !strings.Contains(env, want) { + t.Errorf("env lacks %s:\n%s", want, env) + } + } + if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") { + t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env) + } + b, _ := json.Marshal(s) + if strings.Contains(string(b), "secret") { + t.Fatal("the answer carries a word outside the session's") + } +} + +func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3") + m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3") + s, err := m.finder(nil).Find() + if err != nil || s.FoundIn.PID != 400 { + t.Fatalf("%+v %v", s, err) + } + if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" { + t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s) + } +} + +func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) { + m := newMachine(t) + m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket + _, err := m.finder(active, "i3").Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") { + t.Fatalf("%v", err) + } + var answer map[string]any + if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" { + t.Fatalf("the refusal is structured: %s", err) + } +} + +func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) { + m := newMachine(t) + m.process(600, "sshd", 1, "SSH_CONNECTION=x") + _, err := m.finder(active).Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") { + t.Fatalf("%v", err) + } +} + +func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.socket(filepath.Join(m.x11, "X1")) + m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a") + m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b") + logind := func(id string) (map[string]string, error) { + if id == "a" { + return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil + } + return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil + } + s, err := m.finder(logind, "i3").Find() + if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" { + t.Fatalf("the active session: %+v %v", s, err) + } + remote := func(string) (map[string]string, error) { + return map[string]string{"Active": "yes", "Remote": "yes"}, nil + } + if _, err := m.finder(remote).Find(); !IsNoSession(err) { + t.Fatalf("a remote session is not the operator's desktop: %v", err) + } +} + +func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) { + m := newMachine(t) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "wayland-1")) + m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock") + s, err := m.finder(nil, "sway").Find() + if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" { + t.Fatalf("%+v %v", s, err) + } + if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") { + t.Fatal("the compositor's socket word passes") + } +} + +func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) { + for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} { + if _, ok := DisplayNumber(d); ok != want { + t.Errorf("%q: %v", d, ok) + } + } +} + +func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) { + r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat") + if !r.OK() || r.Stdout != "hello" { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3") + if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here") + if r.OK() || r.Code != 127 { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero") + if !r.Truncated || len(r.Stdout) != MostOutput { + t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated) + } +} + +func TestArgumentsAreReadStrictly(t *testing.T) { + a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if v, err := a.Text("name"); err != nil || v != "x" { + t.Fatal(v, err) + } + if _, err := a.Text("missing"); err == nil { + t.Fatal("a missing required text") + } + if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 { + t.Fatal(n, err) + } + if _, err := a.Whole("f", 0, 0, 5); err == nil { + t.Fatal("1.5 is not whole") + } + if _, err := a.Whole("n", 0, 4, 5); err == nil { + t.Fatal("out of range") + } + if b, given, err := a.Bool("b"); !b || !given || err != nil { + t.Fatal("bool") + } + if _, _, err := a.Bool("name"); err == nil { + t.Fatal("text is not a bool") + } + if l, err := a.Strings("l"); err != nil || len(l) != 2 { + t.Fatal(l, err) + } + if _, err := a.OneOf("name", "", "y", "z"); err == nil { + t.Fatal("not one of") + } +} + +func TestAPathIsKeptInsideTheHome(t *testing.T) { + t.Setenv("MESH_OPERATOR_HOME", "/home/op") + for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} { + if got, err := InHome(in); err != nil || got != want { + t.Errorf("%s: %s %v", in, got, err) + } + } + for _, out := range []string{"/etc/passwd", "~/../other", "../x"} { + if _, err := InHome(out); err == nil { + t.Errorf("%s was accepted", out) + } + } +} + +func TestPropertiesAreParsed(t *testing.T) { + p := ParseProperties("Active=yes\nState=active\nDisplay=\n") + if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" { + t.Fatal(p) + } +} diff --git a/modules/adwaita/module.json b/modules/adwaita/module.json new file mode 100644 index 0000000..c02971e --- /dev/null +++ b/modules/adwaita/module.json @@ -0,0 +1,118 @@ +{ + "module": "adwaita", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "adwaita_appearance", + "adwaita_cursor", + "adwaita_icons", + "adwaita_portal_check" + ], + "environment": { + "variables": { + "GTK_THEME": "Adwaita:dark", + "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", + "QT_QPA_PLATFORMTHEME": "qt6ct", + "QT_STYLE_OVERRIDE": "Fusion", + "QT_SELECT": "6", + "XCURSOR_THEME": "Adwaita", + "XCURSOR_SIZE": "24" + } + }, + "shell": [ + { + "for": "xresources", + "slot": "normal", + "code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" + }, + { + "for": "xinitrc", + "slot": "normal", + "code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n" + } + ], + "resources": [ + { + "id": "package-gnome-themes-extra", + "type": "package", + "package": "gnome-themes-extra" + }, + { + "id": "package-adwaita-icon-theme", + "type": "package", + "package": "adwaita-icon-theme" + }, + { + "id": "package-adwaita-cursors", + "type": "package", + "package": "adwaita-cursors" + }, + { + "id": "package-qt6ct", + "type": "package", + "package": "qt6ct" + }, + { + "id": "package-xdg-desktop-portal-gtk", + "type": "package", + "package": "xdg-desktop-portal-gtk" + }, + { + "id": "gtk3", + "type": "file", + "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + }, + { + "id": "gtk4", + "type": "file", + "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + }, + { + "id": "qt6ct", + "type": "file", + "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n" + }, + { + "id": "portals", + "type": "file", + "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n" + }, + { + "id": "cursor", + "type": "file", + "path": "${machine:account-home}/.icons/default/index.theme", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/adwaita-tools", + "binary": "adwaita-tools", + "loads": [ + "adwaita-tools" + ] + } + ] + } +} diff --git a/modules/clipmenu/README.md b/modules/clipmenu/README.md new file mode 100644 index 0000000..6243582 --- /dev/null +++ b/modules/clipmenu/README.md @@ -0,0 +1,69 @@ +# clipmenu + +The clipboard manager as a module (novox/hq ADR 0208, research 026/04). + +- Installs `clipmenu` from the official repositories. It brings `clipnotify`, `xsel`, `xdotool` and + `dmenu` as its own dependencies. +- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires + `x11-display` on its own machine. +- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it. +- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`). + It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not + merged), and the session start alone is one starter. +- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the + history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu. +- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools + alike: + - `CM_SELECTIONS=clipboard`: what was copied, not every highlighted word. The found greenclip did + the same. + - `CM_MAX_CLIPS=500`: how many clips are kept. + - `CM_HISTLENGTH=15`: how many lines the menu shows. + +## Tools + +| tool | does | +|---|---| +| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) | +| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy | +| `clipmenu_paste` | what the clipboard holds now | +| `clipmenu_clear` | forget the history; the daemon's locks stay | +| `clipmenu_delete` | forget one entry, by id or first line | + +The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's +own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an +owner (`xsel`) under the account's service manager. As a child of the tools runtime, the clipboard +would empty whenever the runtime restarted. + +## What it improves on what was found + +- **No AUR package.** greenclip came from the user repository. clipmenu is in the official one. +- **Started once.** greenclip was started by the window manager on both workstations, and on the + desktop by an enabled user unit as well. +- **No absolute home path** in any configuration. greenclip's named one. +- **The history does not outlive a reboot.** greenclip kept it in `~/.cache`, so every password ever + copied stayed on disk. clipmenu keeps it in the runtime directory, which is memory. +- **One menu.** The history appears in the launcher's own menu, through the seat's `dmenu` command, + instead of a theme from a cloned theme repository. + +## What it leaves as found + +- `~/.config/greenclip.toml` and greenclip's history, `~/.cache/greenclip.history`. +- On the desktop: greenclip's enabled user unit link + (`~/.config/systemd/user/default.target.wants/greenclip.service`). It dangles once the package is + gone. + +## Migration (ADR 0182) + +1. After the first push, delete `~/.config/greenclip.toml` and `~/.cache/greenclip.history`. +2. On the desktop: `systemctl --user disable greenclip.service`, before the push if you can. The + package's removal takes the unit file with it. +3. Until the `i3` module carries the main configuration, the found `exec --no-startup-id greenclip + daemon` and `$mod+period` lines stay in `~/.config/i3/config`. i3 reports `$mod+period` as bound + twice. The `i3` module's configuration carries neither. + +## Blockers + +- `node-clipboard`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows + them, `mctl` reads them as unknown. +- `CM_*` reach the session through `node-env` (ADR 0203), so the account's environment module must + be assigned too. Without it clipmenu runs on its defaults: both selections, 1000 clips, 8 lines. diff --git a/modules/clipmenu/cmd/clipmenu-tools/args.go b/modules/clipmenu/cmd/clipmenu-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-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/clipmenu/cmd/clipmenu-tools/clipboard.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard.go new file mode 100644 index 0000000..b025030 --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard.go @@ -0,0 +1,348 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + mostCopy = 1 << 20 + mostPaste = 64 << 10 + // majorVersion is clipmenu's store layout: /clipmenu../. + majorVersion = 6 +) + +// account is the user clipmenu's store is named for. +func account() string { + for _, k := range []string{"USER", "MESH_OPERATOR_ACCOUNT", "LOGNAME"} { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "" +} + +// storeDir is where clipmenud keeps the history: CM_DIR, else the account's runtime directory. +func storeDir(s Session) (string, error) { + base := os.Getenv("CM_DIR") + if base == "" { + base = s.RuntimeDir + } + if base == "" { + return "", fmt.Errorf("%w: the account's runtime directory, where the clipboard history lives, is missing (the account is not logged in)", ErrNoBus) + } + return filepath.Join(base, fmt.Sprintf("clipmenu.%d.%s", majorVersion, account())), nil +} + +// cksum is POSIX cksum(1) of data: clipmenu names each entry's file by the cksum of its first line +// followed by a newline, as " ". +func cksum(data []byte) string { + var crc uint32 + step := func(b byte) { + crc ^= uint32(b) << 24 + for i := 0; i < 8; i++ { + if crc&0x80000000 != 0 { + crc = crc<<1 ^ 0x04C11DB7 + } else { + crc <<= 1 + } + } + } + for _, b := range data { + step(b) + } + for n := len(data); n != 0; n >>= 8 { + step(byte(n)) + } + return fmt.Sprintf("%d %d", ^crc, len(data)) +} + +func entryID(line string) string { return cksum([]byte(line + "\n")) } + +// Entry is one clip in the history. +type Entry struct { + ID string `json:"id"` + Line string `json:"line"` + At string `json:"at"` + Bytes int `json:"bytes"` + Text string `json:"text,omitempty"` + Truncated bool `json:"truncated,omitempty"` + at int64 +} + +// HistoryResult is what node-clipboard.history answers. +type HistoryResult struct { + Collecting bool `json:"collecting"` + Store string `json:"store"` + Total int `json:"total"` + Entries []Entry `json:"entries"` +} + +// readStore reads clipmenu's line cache: one " " per copy, oldest first, a +// line repeated when the same thing was copied again. The newest copy of each line wins. +func readStore(dir string) ([]Entry, error) { + f, err := os.Open(filepath.Join(dir, "line_cache")) + if errors.Is(err, os.ErrNotExist) { + return []Entry{}, nil + } + if err != nil { + return nil, err + } + defer f.Close() + latest := map[string]int64{} + scan := bufio.NewScanner(f) + scan.Buffer(make([]byte, 64<<10), 1<<20) + for scan.Scan() { + stamp, line, ok := strings.Cut(scan.Text(), " ") + if !ok { + continue + } + ns, err := strconv.ParseInt(stamp, 10, 64) + if err != nil { + continue + } + if ns >= latest[line] { + latest[line] = ns + } + } + out := make([]Entry, 0, len(latest)) + for line, ns := range latest { + out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at }) + return out, scan.Err() +} + +// History is the clipboard's history, newest first. +func History(limit, maxBytes int) (HistoryResult, error) { + dir, err := storeDir(findEnvironment()) + if err != nil { + return HistoryResult{}, err + } + entries, err := readStore(dir) + if err != nil { + return HistoryResult{}, err + } + out := HistoryResult{Collecting: len(processesOf("clipmenud")) > 0, Store: dir, Total: len(entries), Entries: []Entry{}} + for i, e := range entries { + if i == limit { + break + } + if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil { + e.Bytes = int(info.Size()) + if maxBytes > 0 { + raw, _ := os.ReadFile(filepath.Join(dir, e.ID)) + if len(raw) > maxBytes { + raw, e.Truncated = raw[:maxBytes], true + } + e.Text = string(raw) + } + } + out.Entries = append(out.Entries, e) + } + return out, nil +} + +// CopyResult is what node-clipboard.copy answers. +type CopyResult struct { + Bytes int `json:"bytes"` + Unit string `json:"unit"` +} + +// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the +// owner (xsel) runs under the account's service manager, not as a child of this tool. +func Copy(text string) (CopyResult, error) { + if len(text) == 0 { + return CopyResult{}, errors.New("text is empty; to empty the clipboard's history, clipmenu_clear") + } + if len(text) > mostCopy { + return CopyResult{}, fmt.Errorf("%d bytes; the clipboard takes at most %d here", len(text), mostCopy) + } + s, err := findSession() + if err != nil { + return CopyResult{}, err + } + if s.RuntimeDir == "" { + return CopyResult{}, fmt.Errorf("%w: no runtime directory to hand the text over in", ErrNoBus) + } + // Handed over in a file only the account can read, which the owner reads and removes. + f, err := os.CreateTemp(s.RuntimeDir, "clipmenu-copy-") + if err != nil { + return CopyResult{}, err + } + if _, err := f.WriteString(text); err != nil { + f.Close() + os.Remove(f.Name()) + return CopyResult{}, err + } + f.Close() + unit := uniqueUnit("clipmenu-copy") + script := `xsel --nodetach --input --clipboard < "$0" & sleep 1; rm -f "$0"; wait` + if err := s.detach(unit, "/bin/sh", "-c", script, f.Name()); err != nil { + os.Remove(f.Name()) + return CopyResult{}, err + } + return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil +} + +// PasteResult is what clipmenu_paste answers. +type PasteResult struct { + Text string `json:"text"` + Bytes int `json:"bytes"` + Truncated bool `json:"truncated,omitempty"` +} + +// Paste reads the clipboard now. +func Paste() (PasteResult, error) { + s, err := findSession() + if err != nil { + return PasteResult{}, err + } + r, err := s.run(5*time.Second, "", "xsel", "--output", "--clipboard") + if err != nil { + return PasteResult{}, err + } + if r.Code != 0 { + return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr)) + } + out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated} + if len(out.Text) > mostPaste { + out.Text, out.Truncated = out.Text[:mostPaste], true + } + return out, nil +} + +// ChangeResult is what clear and delete answer. +type ChangeResult struct { + Removed int `json:"removed"` + Remaining int `json:"remaining"` +} + +// withStoreLock holds clipmenu's own lock on its store, the one clipmenud and clipdel take, while +// change runs, so a copy arriving meanwhile is neither lost nor half-written. +func withStoreLock(dir string, change func() error) error { + lock, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer lock.Close() + deadline := time.Now().Add(2 * time.Second) + for { + err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if time.Now().After(deadline) { + return fmt.Errorf("the clipboard store is locked by clipmenud and did not come free within 2s") + } + time.Sleep(50 * time.Millisecond) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + return change() +} + +func storeOf() (string, error) { + dir, err := storeDir(findEnvironment()) + if err != nil { + return "", err + } + if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("there is no clipboard history at %s: clipmenud has not run in this login", dir) + } + return dir, nil +} + +// Clear forgets every entry and its text, keeping the store and its locks (clipdel's own clear +// removes the directory, the daemon's lock with it). +func Clear() (ChangeResult, error) { + dir, err := storeOf() + if err != nil { + return ChangeResult{}, err + } + var out ChangeResult + err = withStoreLock(dir, func() error { + entries, err := readStore(dir) + if err != nil { + return err + } + out.Removed = len(entries) + files, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, f := range files { + switch f.Name() { + case "lock", "session_lock", "line_cache": + continue + } + if f.Type().IsRegular() { + if err := os.Remove(filepath.Join(dir, f.Name())); err != nil { + return err + } + } + } + return os.WriteFile(filepath.Join(dir, "line_cache"), nil, 0o600) + }) + return out, err +} + +// Delete forgets one entry, by id or by its first line. +func Delete(id, line string) (ChangeResult, error) { + if (id == "") == (line == "") { + return ChangeResult{}, errors.New("give the entry's id or its line, one of the two") + } + dir, err := storeOf() + if err != nil { + return ChangeResult{}, err + } + var out ChangeResult + err = withStoreLock(dir, func() error { + raw, err := os.ReadFile(filepath.Join(dir, "line_cache")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + var kept []string + for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") { + if l == "" { + continue + } + _, text, _ := strings.Cut(l, " ") + if text == line || (id != "" && entryID(text) == id) { + out.Removed++ + _ = os.Remove(filepath.Join(dir, entryID(text))) + continue + } + kept = append(kept, l) + } + if out.Removed == 0 { + return fmt.Errorf("no entry %s%s in the clipboard history", id, line) + } + content := strings.Join(kept, "\n") + if content != "" { + content += "\n" + } + tmp := filepath.Join(dir, ".line_cache.mesh") + if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(dir, "line_cache")) + }) + if err != nil { + return ChangeResult{}, err + } + entries, _ := readStore(dir) + out.Remaining = len(entries) + return out, nil +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go new file mode 100644 index 0000000..058b8ae --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const nobody = 4194400 + +func TestEntryIdsAreWhatClipmenuNamesItsFiles(t *testing.T) { + for _, line := range []string{"hello", "", "two words (3 lines)", "ünïcode ✓", strings.Repeat("x", 300)} { + out, err := exec.Command("bash", "-c", `cksum <<< "$1"`, "_", line).Output() + if err != nil { + t.Skip("bash or cksum is missing here") + } + if got, want := entryID(line), strings.TrimSpace(string(out)); got != want { + t.Errorf("%q: %s, cksum says %s", line, got, want) + } + } +} + +// store makes clipmenu's store as clipmenud leaves it, for the account the tools run as. +func store(t *testing.T, clips map[string]string, order ...string) string { + t.Helper() + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + t.Setenv("USER", "op") + t.Setenv("CM_DIR", "") + dir := filepath.Join(runtime, "clipmenu.6.op") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil { + t.Fatal(err) + } + var cache strings.Builder + for i, line := range order { + cache.WriteString(strconv.FormatInt(1_700_000_000_000_000_000+int64(i)*1_000_000_000, 10) + " " + line + "\n") + if err := os.WriteFile(filepath.Join(dir, entryID(line)), []byte(clips[line]), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "line_cache"), []byte(cache.String()), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) { + store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first") + fakeProcess(t, nobody, "clipmenud") + h, err := History(10, 4096) + if err != nil { + t.Fatal(err) + } + if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 { + t.Fatalf("%+v", h) + } + if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated { + t.Fatalf("limited and cut: %+v", h) + } + if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 { + t.Fatalf("without text: %+v", h) + } +} + +func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) { + dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a") + r, err := Delete(entryID("a"), "") + if err != nil || r.Removed != 2 || r.Remaining != 2 { + t.Fatalf("by id, both copies: %+v, %v", r, err) + } + if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) { + t.Fatal("the text stayed") + } + if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 { + t.Fatalf("by line: %+v, %v", r, err) + } + if _, err := Delete("", "zzz"); err == nil { + t.Fatal("a missing entry was reported deleted") + } + if _, err := Delete("x", "y"); err == nil { + t.Fatal("both an id and a line were accepted") + } + cache, _ := os.ReadFile(filepath.Join(dir, "line_cache")) + if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 { + t.Fatalf("line cache: %q", cache) + } +} + +func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) { + dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b") + if err := os.WriteFile(filepath.Join(dir, "session_lock"), nil, 0o600); err != nil { + t.Fatal(err) + } + r, err := Clear() + if err != nil || r.Removed != 2 { + t.Fatalf("%+v, %v", r, err) + } + left, _ := os.ReadDir(dir) + var names []string + for _, f := range left { + names = append(names, f.Name()) + } + if strings.Join(names, ",") != "line_cache,lock,session_lock" { + t.Fatalf("left: %v", names) + } +} + +func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) { + store(t, nil) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + r, err := Copy("secret-free text") + if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") { + t.Fatalf("%+v, %v", r, err) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 -- /bin/sh -c xsel --nodetach --input --clipboard") { + t.Fatalf("asked: %s", asked) + } + handed, _ := os.ReadFile(filepath.Join(bin, "log.text")) + if string(handed) != "secret-free text" { + t.Fatalf("handed over: %q", handed) + } + if _, err := Copy(""); err == nil { + t.Fatal("empty text was accepted") + } +} + +func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) { + fakeMachine(t) + if _, err := Paste(); !errors.Is(err, ErrNoSession) { + t.Fatal(err) + } + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`}) + p, err := Paste() + if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 { + t.Fatalf("%+v, %v", p, err) + } +} + +func TestWithoutAStoreTheChangesSayWhy(t *testing.T) { + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + if err := os.MkdirAll(runtime, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("USER", "op") + if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") { + t.Fatal(err) + } + if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting { + t.Fatalf("an empty history: %+v, %v", h, err) + } +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/main.go b/modules/clipmenu/cmd/clipmenu-tools/main.go new file mode 100644 index 0000000..e8e9212 --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-tools/main.go @@ -0,0 +1,90 @@ +// clipmenu's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of +// node-clipboard's verbs `history` and `copy`, and its own tools, served by the node's runtime as the +// operator account. The history is read from clipmenu's own store in the account's runtime directory; +// the clipboard itself is the X session's. +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-clipboard.history", + Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " + + "and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.", + Input: map[string]any{ + "limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"}, + "max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"}, + }, + Run: func(args map[string]any) (any, error) { + limit, err := whole(args, "limit", 20, 1, 500) + if err != nil { + return nil, err + } + most, err := whole(args, "max_bytes", 4096, 0, 65536) + if err != nil { + return nil, err + } + return History(limit, most) + }, + }, + { + Name: "node-clipboard.copy", + Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " + + "like any copy. Answers how many bytes.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": fmt.Sprintf("the text (at most %d bytes)", mostCopy)}, + }, + "required": []string{"text"}, + }, + Run: func(args map[string]any) (any, error) { + t, ok := args["text"].(string) + if !ok { + return nil, fmt.Errorf("text is required, as a string") + } + return Copy(t) + }, + }, + { + Name: "clipmenu_paste", + Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).", + Run: func(map[string]any) (any, error) { return Paste() }, + }, + { + Name: "clipmenu_clear", + Description: "Forget the whole clipboard history. What is on the clipboard now stays there.", + Run: func(map[string]any) (any, error) { return Clear() }, + }, + { + Name: "clipmenu_delete", + Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " + + "its first line exactly.", + Input: map[string]any{ + "id": map[string]any{"type": "string", "description": "the entry's id"}, + "line": map[string]any{"type": "string", "description": "the entry's first line, exactly"}, + }, + Run: func(args map[string]any) (any, error) { + id, err := text(args, "id", false) + if err != nil { + return nil, err + } + line, _ := args["line"].(string) + return Delete(id, line) + }, + }, + } +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-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/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go new file mode 100644 index 0000000..e0adb4c --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history +// and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once +// from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command. + +func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) { + m := readManifest(t) + if m.Module != "clipmenu" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("requires: %v", m.Requires) + } + present, absent := m.packages() + if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" { + t.Fatalf("%+v", m.Shell) + } + code := m.Shell[0].Code + if strings.Count(code, "\nclipmenud &\n") != 1 || strings.Contains(code, "greenclip") { + t.Fatalf("%q", code) + } + for _, r := range m.Resources { + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("a second start: %v", r) + } + } +} + +func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) { + m := readManifest(t) + if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) { + t.Fatalf("%v", m.Environment.Variables) + } + if _, set := m.Environment.Variables["CM_LAUNCHER"]; set { + t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command") + } + m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf") + if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") { + t.Fatalf("%s", c) + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/session.go b/modules/clipmenu/cmd/clipmenu-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-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/clipmenu/cmd/clipmenu-tools/session_test.go b/modules/clipmenu/cmd/clipmenu-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/clipmenu/cmd/clipmenu-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/clipmenu/files/i3/50-clipmenu.conf b/modules/clipmenu/files/i3/50-clipmenu.conf new file mode 100644 index 0000000..41835e6 --- /dev/null +++ b/modules/clipmenu/files/i3/50-clipmenu.conf @@ -0,0 +1,5 @@ +# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at +# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which +# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the +# clipboard. +bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard diff --git a/modules/clipmenu/go.mod b/modules/clipmenu/go.mod new file mode 100644 index 0000000..f6c083d --- /dev/null +++ b/modules/clipmenu/go.mod @@ -0,0 +1,5 @@ +module clipmenu + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/clipmenu/go.sum b/modules/clipmenu/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/clipmenu/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/clipmenu/module.json b/modules/clipmenu/module.json new file mode 100644 index 0000000..1cb1991 --- /dev/null +++ b/modules/clipmenu/module.json @@ -0,0 +1,75 @@ +{ + "module": "clipmenu", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-clipboard", + "scope": "node", + "serves": [ + "history", + "copy" + ] + } + ], + "tools": [ + "clipmenu_paste", + "clipmenu_clear", + "clipmenu_delete" + ], + "environment": { + "variables": { + "CM_SELECTIONS": "clipboard", + "CM_MAX_CLIPS": "500", + "CM_HISTLENGTH": "15" + } + }, + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\nclipmenud &\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "clipmenu" + }, + { + "id": "greenclip", + "type": "package", + "package": "rofi-greenclip", + "absent": true + }, + { + "id": "i3-bindings", + "type": "file", + "path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/clipmenu-tools", + "binary": "clipmenu-tools", + "loads": [ + "clipmenu-tools" + ] + } + ] + } +} diff --git a/modules/dunst/README.md b/modules/dunst/README.md new file mode 100644 index 0000000..59c90c8 --- /dev/null +++ b/modules/dunst/README.md @@ -0,0 +1,60 @@ +# dunst + +The notifier as a module (novox/hq ADR 0208, research 026/05). + +- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use. +- Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`. +- Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's + rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after + `dunstrc`, so a drop-in outranks it. +- **Starts nothing.** The package registers dunst with D-Bus, which starts it on the first + notification, inside the account's service manager. There is no autostart line, no unit and no + session-start contribution. +- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session + has, so it serves an X session and a later sway one alike. + +## Tools + +Every tool goes over the account's session bus. None needs the screen, and each answers clearly when +the account is not logged in. + +| tool | does | +|---|---| +| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id | +| `node-notifier.history` | what was shown, newest first, with how long ago | +| `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost | +| `dunst_close_all` | clear the screen; the history keeps them | +| `dunst_rules` | the rules the running notifier holds, and the files they come from | +| `dunst_count` | shown, waiting, in history, and whether paused | + +## What it chose, and what it improves + +The workstations' files differed: one had the notifications bottom-right, 15 % transparent and with +rounded corners; the other top-right, opaque and square. This module takes the second, because the +rest of the desktop is square and opaque, and the top-right corner sits under the bar that shows the +count. The file keeps only the settings that differ from dunst's defaults. + +- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine. It now calls + `dmenu`, the seat command of whichever module holds `node-launcher` (`rofi` on the workstations). +- **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font. +- `icon_path`, which named two directories of an icon theme that is not installed, is gone. The icon + theme is looked up recursively. + +## What it leaves as found + +- `~/.config/dunst/dunstrc.d/50-slack.conf`, the Slack rule. It becomes the Slack module's own drop-in + when there is one, and until then it is the operator's file in a directory this module owns. + +## Migration (ADR 0182) + +- The first push keeps the found `dunstrc` once, then writes the module's. +- **The desktop runs two notification daemons** because its session began before the session bus + fix (research 026/01). That ends at the next login, and nothing here starts a second one. + `dunst_count` after logging in again shows the one daemon's counts. + +## Blockers + +- `node-notifier` is ADR 0208's seat. Until the controller knows it, `mctl` reads the claim as + unknown. +- `dunst_rules` and the counts ask the running notifier. When none runs, the bus starts one, which + needs a session to draw on. diff --git a/modules/dunst/cmd/dunst-tools/args.go b/modules/dunst/cmd/dunst-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/dunst/cmd/dunst-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/dunst/cmd/dunst-tools/dunst.go b/modules/dunst/cmd/dunst-tools/dunst.go new file mode 100644 index 0000000..a05c3e9 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/dunst.go @@ -0,0 +1,355 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unsafe" +) + +var urgencies = []string{"low", "normal", "critical"} + +const busTimeout = 10 * time.Second + +// Notification is what node-notifier.send shows. +type Notification struct { + Summary string + Body string + Urgency string + AppName string + Icon string + ExpireMS int + Category string + ReplaceID int +} + +func notificationOf(args map[string]any) (Notification, error) { + var n Notification + var err error + if n.Summary, err = text(args, "summary", true); err != nil { + return n, err + } + if n.Body, err = text(args, "body", false); err != nil { + return n, err + } + if n.Urgency, err = text(args, "urgency", false); err != nil { + return n, err + } + if n.Urgency == "" { + n.Urgency = "normal" + } + known := false + for _, u := range urgencies { + known = known || u == n.Urgency + } + if !known { + return n, fmt.Errorf("urgency %q is low, normal or critical", n.Urgency) + } + if n.AppName, err = text(args, "app_name", false); err != nil { + return n, err + } + if n.AppName == "" { + n.AppName = "mesh" + } + if n.Icon, err = text(args, "icon", false); err != nil { + return n, err + } + if n.ExpireMS, err = whole(args, "expire_ms", -1, 0, 24*3600*1000); err != nil { + return n, err + } + if n.Category, err = text(args, "category", false); err != nil { + return n, err + } + n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1) + return n, err +} + +// SendResult is what node-notifier.send answers. +type SendResult struct { + ID int `json:"id"` +} + +// Send shows a notification through the desktop's notification service, whichever runs it. +func Send(n Notification) (SendResult, error) { + s, err := findBus() + if err != nil { + return SendResult{}, err + } + args := []string{"--print-id", "--urgency=" + n.Urgency, "--app-name=" + n.AppName} + if n.Icon != "" { + args = append(args, "--icon="+n.Icon) + } + if n.ExpireMS >= 0 { + args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS)) + } + if n.Category != "" { + args = append(args, "--category="+n.Category) + } + if n.ReplaceID > 0 { + args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID)) + } + // "--" so a title that starts with a dash is a title. + args = append(args, "--", n.Summary) + if n.Body != "" { + args = append(args, n.Body) + } + r, err := s.run(busTimeout, "", "notify-send", args...) + if err != nil { + return SendResult{}, err + } + if r.Code != 0 { + return SendResult{}, fmt.Errorf("notify-send: %s", strings.TrimSpace(r.Stderr)) + } + id, err := strconv.Atoi(strings.TrimSpace(r.Stdout)) + if err != nil { + return SendResult{}, fmt.Errorf("notify-send answered no id: %q", r.Stdout) + } + return SendResult{ID: id}, nil +} + +// dunstctl runs one dunstctl command over the session bus and answers what it printed. +func dunstctl(args ...string) (string, error) { + s, err := findBus() + if err != nil { + return "", err + } + r, err := s.run(busTimeout, "", "dunstctl", args...) + if err != nil { + return "", err + } + if r.Code != 0 { + return "", fmt.Errorf("dunstctl %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr+r.Stdout)) + } + return r.Stdout, nil +} + +// variantMaps reads busctl's JSON form of an array of dictionaries (aa{sv}), which is how dunstctl +// answers history and rules, into plain maps. +func variantMaps(raw string) ([]map[string]any, error) { + var doc struct { + Type string `json:"type"` + Data [][]map[string]struct { + Data any `json:"data"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return nil, fmt.Errorf("dunstctl's answer is not the bus's JSON: %w", err) + } + if doc.Type != "aa{sv}" { + return nil, fmt.Errorf("dunstctl answered %s, not aa{sv}", doc.Type) + } + out := []map[string]any{} + for _, group := range doc.Data { + for _, entry := range group { + m := map[string]any{} + for k, v := range entry { + m[k] = v.Data + } + out = append(out, m) + } + } + return out, nil +} + +// Shown is one notification in the history. +type Shown struct { + ID int `json:"id"` + AppName string `json:"app_name"` + Summary string `json:"summary"` + Body string `json:"body,omitempty"` + Urgency string `json:"urgency"` + Category string `json:"category,omitempty"` + AgeSeconds int64 `json:"age_seconds"` +} + +// HistoryResult is what node-notifier.history answers. +type HistoryResult struct { + Total int `json:"total"` + Notifications []Shown `json:"notifications"` +} + +// History is dunst's history, newest first. +func History(limit int) (HistoryResult, error) { + raw, err := dunstctl("history") + if err != nil { + return HistoryResult{}, err + } + return parseHistory(raw, monotonicMicros(), limit) +} + +func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) { + entries, err := variantMaps(raw) + if err != nil { + return HistoryResult{}, err + } + out := HistoryResult{Total: len(entries), Notifications: []Shown{}} + type stamped struct { + Shown + at int64 + } + var all []stamped + for _, e := range entries { + at := number(e["timestamp"]) + all = append(all, stamped{Shown{ + ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]), + Urgency: strings.ToLower(str(e["urgency"])), Category: str(e["category"]), + AgeSeconds: max(0, (nowMicros-at)/1_000_000), + }, at}) + } + sort.SliceStable(all, func(i, j int) bool { return all[i].at > all[j].at }) + for i, s := range all { + if i == limit { + break + } + out.Notifications = append(out.Notifications, s.Shown) + } + return out, nil +} + +func number(v any) int64 { + switch n := v.(type) { + case float64: + return int64(n) + case json.Number: + i, _ := n.Int64() + return i + } + return 0 +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +// monotonicMicros is the clock dunst stamps its notifications with (CLOCK_MONOTONIC, microseconds). +func monotonicMicros() int64 { + var ts syscall.Timespec + const clockMonotonic = 1 + if _, _, errno := syscall.Syscall(syscall.SYS_CLOCK_GETTIME, clockMonotonic, uintptr(unsafe.Pointer(&ts)), 0); errno != 0 { + return 0 + } + return ts.Sec*1_000_000 + ts.Nsec/1000 +} + +// PauseResult is what dunst_pause and dunst_resume answer. +type PauseResult struct { + Paused bool `json:"paused"` + Note string `json:"note"` +} + +// SetPaused turns do-not-disturb on or off. +func SetPaused(on bool) (PauseResult, error) { + if _, err := dunstctl("set-paused", strconv.FormatBool(on)); err != nil { + return PauseResult{}, err + } + out, err := dunstctl("is-paused") + if err != nil { + return PauseResult{}, err + } + paused := strings.TrimSpace(out) == "true" + note := "notifications are shown" + if paused { + note = "notifications are held back until dunst_resume; the next login starts unpaused" + } + return PauseResult{Paused: paused, Note: note}, nil +} + +// CloseAll closes what is on screen. +func CloseAll() (CountResult, error) { + if _, err := dunstctl("close-all"); err != nil { + return CountResult{}, err + } + return Count() +} + +// CountResult is what dunst_count answers. +type CountResult struct { + Displayed int `json:"displayed"` + Waiting int `json:"waiting"` + History int `json:"history"` + Paused bool `json:"paused"` +} + +// Count is how many notifications are where. +func Count() (CountResult, error) { + var c CountResult + for _, part := range []struct { + which string + into *int + }{{"displayed", &c.Displayed}, {"waiting", &c.Waiting}, {"history", &c.History}} { + out, err := dunstctl("count", part.which) + if err != nil { + return c, err + } + n, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return c, fmt.Errorf("dunstctl count %s answered %q", part.which, out) + } + *part.into = n + } + out, err := dunstctl("is-paused") + if err != nil { + return c, err + } + c.Paused = strings.TrimSpace(out) == "true" + return c, nil +} + +// Rule is one notifier rule in force. +type Rule struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Sets map[string]any `json:"sets"` +} + +// RulesResult is what dunst_rules answers. +type RulesResult struct { + Files []string `json:"files"` + Rules []Rule `json:"rules"` +} + +// Rules are the rules the running notifier holds, and the files it reads them from. +func Rules() (RulesResult, error) { + raw, err := dunstctl("rules", "--json") + if err != nil { + return RulesResult{}, err + } + return parseRules(raw, configFiles(filepath.Join(operatorHome(), ".config", "dunst"))) +} + +func parseRules(raw string, files []string) (RulesResult, error) { + entries, err := variantMaps(raw) + if err != nil { + return RulesResult{}, err + } + out := RulesResult{Files: files, Rules: []Rule{}} + for _, e := range entries { + r := Rule{Name: str(e["name"]), Enabled: e["enabled"] == true, Sets: map[string]any{}} + for k, v := range e { + if k != "name" && k != "enabled" { + r.Sets[k] = v + } + } + out.Rules = append(out.Rules, r) + } + sort.SliceStable(out.Rules, func(i, j int) bool { return out.Rules[i].Name < out.Rules[j].Name }) + return out, nil +} + +// configFiles are dunstrc and its drop-ins in the order dunst reads them. +func configFiles(dir string) []string { + files := []string{} + if _, err := os.Stat(filepath.Join(dir, "dunstrc")); err == nil { + files = append(files, filepath.Join(dir, "dunstrc")) + } + dropins, _ := filepath.Glob(filepath.Join(dir, "dunstrc.d", "*.conf")) + sort.Strings(dropins) + return append(files, dropins...) +} diff --git a/modules/dunst/cmd/dunst-tools/dunst_test.go b/modules/dunst/cmd/dunst-tools/dunst_test.go new file mode 100644 index 0000000..7faae0f --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/dunst_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +// withBus gives the fake machine the account's runtime directory and bus socket. +func withBus(t *testing.T) string { + t.Helper() + root := fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + 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) + } + return root +} + +// A history as dunstctl answers it (busctl's JSON), two entries out of order. +const history = `{"type":"aa{sv}","data":[[ + {"body":{"type":"s","data":"the build is green"},"summary":{"type":"s","data":"CI"},"appname":{"type":"s","data":"mesh"}, + "category":{"type":"s","data":""},"id":{"type":"i","data":7},"timestamp":{"type":"x","data":100000000},"urgency":{"type":"s","data":"NORMAL"}}, + {"body":{"type":"s","data":""},"summary":{"type":"s","data":"Battery low"},"appname":{"type":"s","data":"upower"}, + "category":{"type":"s","data":"device"},"id":{"type":"i","data":9},"timestamp":{"type":"x","data":160000000},"urgency":{"type":"s","data":"CRITICAL"}} +]]}` + +func TestTheHistoryIsNewestFirstWithAgesFromDunstsOwnClock(t *testing.T) { + got, err := parseHistory(history, 200_000_000, 20) + if err != nil { + t.Fatal(err) + } + want := []Shown{ + {ID: 9, AppName: "upower", Summary: "Battery low", Urgency: "critical", Category: "device", AgeSeconds: 40}, + {ID: 7, AppName: "mesh", Summary: "CI", Body: "the build is green", Urgency: "normal", AgeSeconds: 100}, + } + if got.Total != 2 || !reflect.DeepEqual(got.Notifications, want) { + t.Fatalf("%+v", got) + } + if got, _ := parseHistory(history, 200_000_000, 1); len(got.Notifications) != 1 || got.Total != 2 { + t.Fatalf("limited: %+v", got) + } + if _, err := parseHistory(`{"type":"as","data":[]}`, 0, 1); err == nil { + t.Fatal("an answer of another type was accepted") + } + if monotonicMicros() <= 0 { + t.Fatal("the monotonic clock") + } +} + +func TestSendAsksNotifySendOverTheAccountsBusAndAnswersTheId(t *testing.T) { + withBus(t) + bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo >> "$LOG"; echo "bus=$DBUS_SESSION_BUS_ADDRESS" >> "$LOG"; echo 42`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + n, err := notificationOf(map[string]any{"summary": "-dash title", "body": "hello", "urgency": "critical", "expire_ms": float64(0)}) + if err != nil { + t.Fatal(err) + } + got, err := Send(n) + if err != nil || got.ID != 42 { + t.Fatalf("%+v, %v", got, err) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + want := "[--print-id][--urgency=critical][--app-name=mesh][--expire-time=0][--][-dash title][hello]\nbus=unix:path=" + + filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "bus") + "\n" + if string(asked) != want { + t.Fatalf("notify-send was asked:\n%s\nwant:\n%s", asked, want) + } +} + +func TestANotificationIsRefusedForWhatItCannotBe(t *testing.T) { + for _, bad := range []map[string]any{{}, {"summary": " "}, {"summary": "x", "urgency": "urgent"}, {"summary": "x", "expire_ms": float64(-5)}} { + if _, err := notificationOf(bad); err == nil { + t.Errorf("accepted %v", bad) + } + } + n, _ := notificationOf(map[string]any{"summary": "x"}) + if n.Urgency != "normal" || n.AppName != "mesh" || n.ExpireMS != -1 { + t.Fatalf("defaults: %+v", n) + } +} + +func TestWithoutABusTheToolsSaySo(t *testing.T) { + fakeMachine(t) + if _, err := Send(Notification{Summary: "x"}); !errors.Is(err, ErrNoBus) { + t.Fatal(err) + } + if _, err := Count(); !errors.Is(err, ErrNoBus) { + t.Fatal(err) + } +} + +func TestCountPauseAndCloseAllAreDunstctlsAnswers(t *testing.T) { + withBus(t) + bin := fakeBinaries(t, map[string]string{"dunstctl": `echo "$*" >> "$LOG" +case "$*" in + "count displayed") echo 1 ;; + "count waiting") echo 0 ;; + "count history") echo 12 ;; + is-paused) cat "$STATE" 2>/dev/null || echo false ;; + "set-paused true") echo true > "$STATE" ;; + "set-paused false") echo false > "$STATE" ;; +esac`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + t.Setenv("STATE", filepath.Join(bin, "paused")) + c, err := Count() + if err != nil || c != (CountResult{Displayed: 1, History: 12}) { + t.Fatalf("%+v, %v", c, err) + } + p, err := SetPaused(true) + if err != nil || !p.Paused || !strings.Contains(p.Note, "held back") { + t.Fatalf("%+v, %v", p, err) + } + if c, _ := CloseAll(); !c.Paused { + t.Fatalf("close-all answers the counts: %+v", c) + } + if p, _ := SetPaused(false); p.Paused { + t.Fatalf("resumed: %+v", p) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "close-all\n") { + t.Fatalf("dunstctl was asked:\n%s", log) + } +} + +func TestRulesAreTheRunningNotifiersWithTheFilesTheyComeFrom(t *testing.T) { + root := t.TempDir() + for _, f := range []string{"dunstrc", "dunstrc.d/50-slack.conf", "dunstrc.d/10-mail.conf", "dunstrc.d/notes.txt"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { + t.Fatal(err) + } + } + raw := `{"type":"aa{sv}","data":[[{"enabled":{"type":"b","data":true},"appname":{"type":"s","data":"Slack"}, + "fc":{"type":"s","data":"#6715ebff"},"name":{"type":"s","data":"slack"},"timeout":{"type":"x","data":10000000}}]]}` + got, err := parseRules(raw, configFiles(root)) + if err != nil { + t.Fatal(err) + } + if len(got.Rules) != 1 || got.Rules[0].Name != "slack" || !got.Rules[0].Enabled || got.Rules[0].Sets["appname"] != "Slack" { + t.Fatalf("%+v", got) + } + var names []string + for _, f := range got.Files { + rel, _ := filepath.Rel(root, f) + names = append(names, rel) + } + if !reflect.DeepEqual(names, []string{"dunstrc", "dunstrc.d/10-mail.conf", "dunstrc.d/50-slack.conf"}) { + t.Fatalf("files: %v", names) + } +} diff --git a/modules/dunst/cmd/dunst-tools/main.go b/modules/dunst/cmd/dunst-tools/main.go new file mode 100644 index 0000000..bd61453 --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/main.go @@ -0,0 +1,91 @@ +// dunst's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of +// node-notifier's verbs `send` and `history`, and its own tools, served by the node's runtime as the +// operator account. Everything here goes over the account's session bus; none of it needs the screen. +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-notifier.send", + Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " + + "normal, critical), and optionally the sending application's name, an icon and how long it stays. " + + "Answers the notification's id.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "summary": map[string]any{"type": "string", "description": "the title"}, + "body": map[string]any{"type": "string", "description": "the text; simple markup (, , , ) is shown"}, + "urgency": map[string]any{"type": "string", "enum": urgencies, "description": "default normal"}, + "app_name": map[string]any{"type": "string", "description": "who it is from (default: mesh); a notifier rule can match it"}, + "icon": map[string]any{"type": "string", "description": "an icon name from the icon theme, or a file"}, + "expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"}, + "category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"}, + "replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"}, + }, + "required": []string{"summary"}, + }, + Run: func(args map[string]any) (any, error) { + n, err := notificationOf(args) + if err != nil { + return nil, err + } + return Send(n) + }, + }, + { + Name: "node-notifier.history", + Description: "The notifications the operator was shown, newest first: id, application, title, " + + "body, urgency and how long ago.", + Input: map[string]any{ + "limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"}, + }, + Run: func(args map[string]any) (any, error) { + limit, err := whole(args, "limit", 20, 1, 200) + if err != nil { + return nil, err + } + return History(limit) + }, + }, + { + Name: "dunst_pause", + Description: "Do not disturb: hold every new notification back until resumed. They are shown then, not lost.", + Run: func(map[string]any) (any, error) { return SetPaused(true) }, + }, + { + Name: "dunst_resume", + Description: "End do-not-disturb: notifications held back are shown.", + Run: func(map[string]any) (any, error) { return SetPaused(false) }, + }, + { + Name: "dunst_close_all", + Description: "Close every notification on screen. They stay in the history.", + Run: func(map[string]any) (any, error) { return CloseAll() }, + }, + { + Name: "dunst_rules", + Description: "The notifier's rules in force — each rule's name, whether it is enabled, what it " + + "matches and what it sets — and the files they come from (the mesh's dunstrc, then dunstrc.d).", + Run: func(map[string]any) (any, error) { return Rules() }, + }, + { + Name: "dunst_count", + Description: "How many notifications are shown, waiting and in the history, and whether do-not-disturb is on.", + Run: func(map[string]any) (any, error) { return Count() }, + }, + } +} diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/dunst/cmd/dunst-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/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go new file mode 100644 index 0000000..6c7fb3e --- /dev/null +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// dunst's shape (novox/hq ADR 0208): it claims node-notifier serving send and history, owns its +// dunstrc and the drop-in directory other modules' rules go in, and starts nothing — D-Bus starts it +// on the first notification. It draws only once a notification arrives, through the bus's +// activation, so it requires no display of its own. + +func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) { + m := readManifest(t) + if m.Module != "dunst" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) { + m := readManifest(t) + m.sameAsSource(t, "configuration", "files/dunstrc") + if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" { + t.Fatalf("path: %v", p) + } + if d := m.resource(t, "dropins"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/dunst/dunstrc.d" { + t.Fatalf("drop-ins: %v", d) + } + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "dunstrc.d/") { + t.Fatalf("a rule of another module's: %v", r) + } + } +} + +func TestTheFileIsTheDecidedOneAndCallsTheSeatsMenu(t *testing.T) { + m := readManifest(t) + c := m.resource(t, "configuration")["content"].(string) + for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter 10", "dmenu = dmenu -p dunst"} { + if !strings.Contains(c, " "+want+"\n") { + t.Errorf("lacks %q", want) + } + } + for _, never := range []string{"/usr/bin/dmenu", "Hack", "icon_path", "/home/"} { + if strings.Contains(c, never) { + t.Errorf("names %q", never) + } + } +} + +func TestNothingStartsItButTheBus(t *testing.T) { + m := readManifest(t) + if m.Shell != nil { + t.Fatalf("a session start: %+v", m.Shell) + } + for _, r := range m.Resources { + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("a unit: %v", r) + } + if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "i3/config.d") { + t.Fatalf("a start: %v", r) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/dunst/cmd/dunst-tools/session.go b/modules/dunst/cmd/dunst-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/dunst/cmd/dunst-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/dunst/cmd/dunst-tools/session_test.go b/modules/dunst/cmd/dunst-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/dunst/cmd/dunst-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/dunst/files/dunstrc b/modules/dunst/files/dunstrc new file mode 100644 index 0000000..f504485 --- /dev/null +++ b/modules/dunst/files/dunstrc @@ -0,0 +1,92 @@ +# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced +# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in +# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the +# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on. +# +# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file, +# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it. + +[global] + monitor = 0 + follow = none + + # Geometry + width = 250 + height = (0, 300) + origin = top-right + offset = (10, 50) + notification_limit = 20 + + progress_bar = true + progress_bar_height = 10 + progress_bar_frame_width = 1 + progress_bar_min_width = 150 + progress_bar_max_width = 300 + + indicate_hidden = yes + transparency = 0 + separator_height = 2 + padding = 8 + horizontal_padding = 8 + text_icon_padding = 0 + frame_width = 3 + frame_color = "#de5200" + gap_size = 0 + separator_color = frame + sort = yes + corner_radius = 0 + + # Text: the interface face (research 026/04) + font = Inter 10 + line_height = 0 + markup = full + format = "%s\n%b" + alignment = left + vertical_alignment = center + show_age_threshold = 60 + ellipsize = middle + ignore_newline = no + stack_duplicates = true + hide_duplicate_count = false + show_indicators = yes + + # Icons, from the desktop's icon theme + enable_recursive_icon_lookup = true + icon_theme = Adwaita + icon_position = left + min_icon_size = 32 + max_icon_size = 128 + + # History + sticky_history = yes + history_length = 20 + + # The context menu is the node's dmenu-compatible command, which the holder of node-launcher + # answers (rofi on the workstations). Links open in the desktop's default browser. + dmenu = dmenu -p dunst + browser = /usr/bin/xdg-open + always_run_script = true + + title = Dunst + class = Dunst + ignore_dbusclose = false + + mouse_left_click = close_current + mouse_middle_click = do_action, close_current + mouse_right_click = close_all + +[urgency_low] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_normal] + background = "#000000" + foreground = "#ffffff" + timeout = 10 + +[urgency_critical] + background = "#000000" + foreground = "#ffffff" + frame_color = "#ff0000" + timeout = 0 diff --git a/modules/dunst/go.mod b/modules/dunst/go.mod new file mode 100644 index 0000000..d26e306 --- /dev/null +++ b/modules/dunst/go.mod @@ -0,0 +1,5 @@ +module dunst + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/dunst/go.sum b/modules/dunst/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/dunst/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/dunst/module.json b/modules/dunst/module.json new file mode 100644 index 0000000..6718098 --- /dev/null +++ b/modules/dunst/module.json @@ -0,0 +1,73 @@ +{ + "module": "dunst", + "version": "1", + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-notifier", + "scope": "node", + "serves": [ + "send", + "history" + ] + } + ], + "tools": [ + "dunst_pause", + "dunst_resume", + "dunst_close_all", + "dunst_rules", + "dunst_count" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "dunst" + }, + { + "id": "client", + "type": "package", + "package": "libnotify" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/dunst", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "dropins", + "type": "directory", + "path": "${machine:account-home}/.config/dunst/dunstrc.d", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/dunst/dunstrc", + "owner": "${machine:account}", + "mode": "0644", + "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = 250\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter 10\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/dunst-tools", + "binary": "dunst-tools", + "loads": [ + "dunst-tools" + ] + } + ] + } +} diff --git a/modules/feh/README.md b/modules/feh/README.md new file mode 100644 index 0000000..028506d --- /dev/null +++ b/modules/feh/README.md @@ -0,0 +1,47 @@ +# feh + +The wallpaper as a module (novox/hq ADR 0208, research 026/05). + +- Installs `feh` and requires `x11-display` on its own machine. It holds no seat: a wallpaper is not a + role anything else calls. +- **Carries the wallpaper itself.** `wallpaper/default.jpg` is built into an archive of the module + (ADR 0205) and unpacked into `~/.local/share/feh/wallpapers/`, which the module owns. +- Owns `~/.fehbg`, which sets that image, filled, on every monitor, without rewriting itself + (`--no-fehbg`). +- Runs `~/.fehbg` once per session, from the session's start (the `xinitrc` slot `normal`). +- Binds `$mod+Shift+b` to the same file, as its own i3 drop-in (`50-feh.conf`): the declared wallpaper + back, after a monitor change. + +## Tools + +| tool | does | +|---|---| +| `feh_set` | set images (one for all monitors, or one each) in a mode: fill, center, max, scale, tile. For this session; the declared wallpaper returns at the next login | +| `feh_current` | the declared wallpaper (from `~/.fehbg`) and the one `feh_set` put up in this session | + +`feh_set` never writes `~/.fehbg`. A wallpaper that should stay is a change to this module, or a +setting once issue 168 closes, not a file the next push would overwrite. + +## What it improves on what was found + +- **The wallpaper no longer lives in the predecessor's tree.** `~/.fehbg` pointed into a directory + of the retired predecessor's. Deleting that directory would have left the desktop black, silently. +- **One image file, the same on both workstations.** The image was byte-identical on both. It is now + the module's own. + +## What it leaves as found + +- The predecessor's wallpaper directory. It is part of the predecessor's tree, which goes as a whole. + +## Migration (ADR 0182) + +- The first push keeps the found `~/.fehbg` once, then writes the module's. +- Once the `xorg` module writes the session's start, delete the `~/.fehbg &` line from your own part + of `~/.xinitrc`. +- The image's origin is the predecessor's desktop module. Check that it may be redistributed before + this catalogue is published anywhere public. + +## Blockers + +- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads + them as unknown. diff --git a/modules/feh/cmd/feh-tools/args.go b/modules/feh/cmd/feh-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/feh/cmd/feh-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/feh/cmd/feh-tools/main.go b/modules/feh/cmd/feh-tools/main.go new file mode 100644 index 0000000..3a42f8e --- /dev/null +++ b/modules/feh/cmd/feh-tools/main.go @@ -0,0 +1,53 @@ +// feh's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the wallpaper's 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: "feh_set", + Description: "Set the wallpaper in the operator's session: one image for every monitor, or one per " + + "monitor in the X screen order, filled, centred, scaled to fit, stretched or tiled. Lasts until the " + + "next session start, when the declared wallpaper returns; the declared one is untouched.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "image files on this machine, absolute or under the account's home; one per monitor, or one for all"}, + "mode": map[string]any{"type": "string", "enum": modes, "description": "default fill"}, + }, + "required": []string{"images"}, + }, + Run: func(args map[string]any) (any, error) { + images, err := texts(args, "images") + if err != nil { + return nil, err + } + mode, err := text(args, "mode", false) + if err != nil { + return nil, err + } + return Set(images, mode) + }, + }, + { + Name: "feh_current", + Description: "The wallpaper: the declared one the session start sets (images and mode, read from " + + "~/.fehbg), and the one feh_set put up in this session, if any.", + Run: func(map[string]any) (any, error) { return Current() }, + }, + } +} diff --git a/modules/feh/cmd/feh-tools/manifest_helpers_test.go b/modules/feh/cmd/feh-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/feh/cmd/feh-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/feh/cmd/feh-tools/manifest_test.go b/modules/feh/cmd/feh-tools/manifest_test.go new file mode 100644 index 0000000..dbdd9ab --- /dev/null +++ b/modules/feh/cmd/feh-tools/manifest_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// feh's shape (novox/hq ADR 0208): no seat; it requires the X display on its own machine, carries +// the wallpaper as its own archive (ADR 0205), owns ~/.fehbg pointing at it, and sets it once from +// the session's start. + +func TestItRequiresTheXDisplayAndClaimsNothing(t *testing.T) { + m := readManifest(t) + if m.Module != "feh" || m.Seats != nil || m.Claims != nil { + t.Fatalf("module %q, seats %v, claims %v", m.Module, m.Seats, 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{"feh"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestTheWallpaperIsTheModulesOwnArchive(t *testing.T) { + m := readManifest(t) + a := m.resource(t, "wallpapers") + if a["type"] != "archive" || a["artifact"] != "wallpapers" || a["path"] != "${machine:account-home}/.local/share/feh/wallpapers" || a["owner"] != "${machine:account}" { + t.Fatalf("%v", a) + } + found := false + for _, art := range m.Build.Artifacts { + if art["name"] == "wallpapers" && art["kind"] == "archive" && art["from"] == "wallpaper" { + found = true + } + } + if !found { + t.Fatal("no archive artifact built from wallpaper/") + } + info, err := os.Stat(filepath.Join("..", "..", "wallpaper", "default.jpg")) + if err != nil || info.Size() == 0 { + t.Fatalf("the image: %v", err) + } +} + +func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) { + m := readManifest(t) + m.sameAsSource(t, "fehbg", "files/fehbg") + f := m.resource(t, "fehbg") + if f["path"] != "${machine:account-home}/.fehbg" || f["mode"] != "0755" { + t.Fatalf("%v", f) + } + if c := f["content"].(string); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") { + t.Fatalf("%s", c) + } + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || strings.Count(m.Shell[0].Code, `"$HOME/.fehbg"`) != 1 { + t.Fatalf("%+v", m.Shell) + } +} + +func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) { + m := readManifest(t) + m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf") + if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" { + t.Fatalf("path: %v", p) + } + if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") { + t.Fatalf("%s", c) + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/feh/cmd/feh-tools/session.go b/modules/feh/cmd/feh-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/feh/cmd/feh-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/feh/cmd/feh-tools/session_test.go b/modules/feh/cmd/feh-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/feh/cmd/feh-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/feh/cmd/feh-tools/wallpaper.go b/modules/feh/cmd/feh-tools/wallpaper.go new file mode 100644 index 0000000..d4b7333 --- /dev/null +++ b/modules/feh/cmd/feh-tools/wallpaper.go @@ -0,0 +1,174 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// modes are feh's background modes, by the word feh_set takes. +var modes = []string{"fill", "center", "max", "scale", "tile"} + +// Wallpaper is images and how they are laid on the screens. +type Wallpaper struct { + Images []string `json:"images"` + Mode string `json:"mode"` + At string `json:"at,omitempty"` +} + +func fehbg() string { return filepath.Join(operatorHome(), ".fehbg") } + +// sessionRecord is where feh_set notes what it put up, for feh_current: in the runtime directory, +// so it lasts exactly as long as the login, like the wallpaper itself. +func sessionRecord(s Session) string { + if s.RuntimeDir == "" { + return "" + } + return filepath.Join(s.RuntimeDir, "feh", "current.json") +} + +// SetResult is what feh_set answers. +type SetResult struct { + Wallpaper + Note string `json:"note"` +} + +// Set puts images up as the wallpaper for this session. +func Set(images []string, mode string) (SetResult, error) { + if mode == "" { + mode = "fill" + } + known := false + for _, m := range modes { + known = known || m == mode + } + if !known { + return SetResult{}, fmt.Errorf("mode %q is one of %s", mode, strings.Join(modes, ", ")) + } + if len(images) == 0 { + return SetResult{}, errors.New("images is required: at least one image") + } + var paths []string + for _, img := range images { + p := img + if strings.HasPrefix(p, "~/") { + p = filepath.Join(operatorHome(), p[2:]) + } + if !filepath.IsAbs(p) { + p = filepath.Join(operatorHome(), p) + } + info, err := os.Stat(p) + if err != nil { + return SetResult{}, fmt.Errorf("image %s: %w", img, err) + } + if info.IsDir() { + return SetResult{}, fmt.Errorf("image %s is a directory", img) + } + paths = append(paths, p) + } + s, err := findSession() + if err != nil { + return SetResult{}, err + } + args := append([]string{"--no-fehbg", "--bg-" + mode}, paths...) + r, err := s.run(15*time.Second, "", "feh", args...) + if err != nil { + return SetResult{}, err + } + if r.Code != 0 { + return SetResult{}, fmt.Errorf("feh: %s", strings.TrimSpace(r.Stderr)) + } + w := Wallpaper{Images: paths, Mode: mode, At: time.Now().Format(time.RFC3339)} + if rec := sessionRecord(s); rec != "" { + if err := os.MkdirAll(filepath.Dir(rec), 0o700); err == nil { + raw, _ := json.Marshal(w) + _ = os.WriteFile(rec, raw, 0o600) + } + } + return SetResult{Wallpaper: w, Note: "for this session; the declared wallpaper returns at the next login"}, nil +} + +// CurrentResult is what feh_current answers. +type CurrentResult struct { + Declared *Wallpaper `json:"declared"` + Session *Wallpaper `json:"session,omitempty"` +} + +var bgMode = regexp.MustCompile(`--bg-(fill|center|max|scale|tile)\b`) + +// Current is the declared wallpaper and the one set in this session. +func Current() (CurrentResult, error) { + var out CurrentResult + if raw, err := os.ReadFile(fehbg()); err == nil { + out.Declared = parseFehbg(string(raw), operatorHome()) + } + if rec := sessionRecord(findEnvironment()); rec != "" { + if raw, err := os.ReadFile(rec); err == nil { + var w Wallpaper + if json.Unmarshal(raw, &w) == nil { + out.Session = &w + } + } + } + return out, nil +} + +// parseFehbg reads the feh line of a ~/.fehbg: its mode and its images, with $HOME expanded. +func parseFehbg(script, home string) *Wallpaper { + for _, line := range strings.Split(script, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "feh ") { + continue + } + w := &Wallpaper{Images: []string{}} + if m := bgMode.FindStringSubmatch(line); m != nil { + w.Mode = m[1] + } + for _, word := range shellWords(line)[1:] { + if strings.HasPrefix(word, "-") { + continue + } + word = strings.ReplaceAll(strings.ReplaceAll(word, "${HOME}", home), "$HOME", home) + w.Images = append(w.Images, word) + } + return w + } + return nil +} + +// shellWords splits a simple command line on blanks, honouring single and double quotes. +func shellWords(line string) []string { + var words []string + var cur strings.Builder + var quote byte + in := false + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case quote != 0 && c == quote: + quote = 0 + case quote != 0: + cur.WriteByte(c) + case c == '\'' || c == '"': + quote, in = c, true + case c == ' ' || c == '\t': + if in { + words = append(words, cur.String()) + cur.Reset() + in = false + } + default: + cur.WriteByte(c) + in = true + } + } + if in { + words = append(words, cur.String()) + } + return words +} diff --git a/modules/feh/cmd/feh-tools/wallpaper_test.go b/modules/feh/cmd/feh-tools/wallpaper_test.go new file mode 100644 index 0000000..135dc3b --- /dev/null +++ b/modules/feh/cmd/feh-tools/wallpaper_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +const nobody = 4194400 + +func TestTheDeclaredWallpaperIsReadFromTheModulesFehbg(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "files", "fehbg")) + if err != nil { + t.Fatal(err) + } + w := parseFehbg(string(raw), "/home/op") + if w == nil || w.Mode != "fill" || !reflect.DeepEqual(w.Images, []string{"/home/op/.local/share/feh/wallpapers/default.jpg"}) { + t.Fatalf("%+v", w) + } + // The form feh writes itself, single-quoted, two monitors. + w = parseFehbg("#!/bin/sh\nfeh --no-fehbg --bg-center '/a b/one.png' '/two.png' \n", "/home/op") + if w.Mode != "center" || !reflect.DeepEqual(w.Images, []string{"/a b/one.png", "/two.png"}) { + t.Fatalf("%+v", w) + } + if parseFehbg("#!/bin/sh\n", "/h") != nil { + t.Fatal("a file without feh declares a wallpaper") + } +} + +func TestSetPutsTheImagesUpForThisSessionAndCurrentSaysSo(t *testing.T) { + root := fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil { + t.Fatal(err) + } + home := filepath.Join(root, "home") + for _, f := range []string{"Pictures/a.jpg", "Pictures/b.jpg"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(home, f)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, f), []byte("jpg"), 0o644); err != nil { + t.Fatal(err) + } + } + src, _ := os.ReadFile(filepath.Join("..", "..", "files", "fehbg")) + if err := os.WriteFile(filepath.Join(home, ".fehbg"), src, 0o755); err != nil { + t.Fatal(err) + } + bin := fakeBinaries(t, map[string]string{"feh": `echo "$* DISPLAY=$DISPLAY" > "$LOG"`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + got, err := Set([]string{"~/Pictures/a.jpg", "Pictures/b.jpg"}, "scale") + if err != nil || !strings.Contains(got.Note, "next login") { + t.Fatalf("%+v, %v", got, err) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + want := "--no-fehbg --bg-scale " + filepath.Join(home, "Pictures/a.jpg") + " " + filepath.Join(home, "Pictures/b.jpg") + " DISPLAY=:1\n" + if string(asked) != want { + t.Fatalf("feh was asked %q, want %q", asked, want) + } + cur, err := Current() + if err != nil || cur.Declared == nil || cur.Session == nil || cur.Session.Mode != "scale" || len(cur.Session.Images) != 2 || + cur.Declared.Images[0] != filepath.Join(home, ".local/share/feh/wallpapers/default.jpg") { + t.Fatalf("%+v, %v", cur, err) + } +} + +func TestSetRefusesWhatFehCannotShow(t *testing.T) { + fakeMachine(t) + if _, err := Set([]string{"/nowhere.jpg"}, ""); err == nil { + t.Fatal("a missing image was accepted") + } + if _, err := Set([]string{"/"}, ""); err == nil { + t.Fatal("a directory was accepted") + } + if _, err := Set([]string{"/etc/hostname"}, "stretch"); err == nil { + t.Fatal("an unknown mode was accepted") + } + if _, err := Set(nil, ""); err == nil { + t.Fatal("no image was accepted") + } + f := filepath.Join(t.TempDir(), "x.jpg") + if err := os.WriteFile(f, nil, 0o644); err != nil { + t.Fatal(err) + } + if _, err := Set([]string{f}, ""); !errors.Is(err, ErrNoSession) { + t.Fatalf("without a session: %v", err) + } +} diff --git a/modules/feh/files/fehbg b/modules/feh/files/fehbg new file mode 100755 index 0000000..a3159bb --- /dev/null +++ b/modules/feh/files/fehbg @@ -0,0 +1,6 @@ +#!/bin/sh +# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The +# session's start runs it, and so may anything that wants the declared wallpaper back. The image is +# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session +# without touching this file. +feh --no-fehbg --bg-fill "$HOME/.local/share/feh/wallpapers/default.jpg" diff --git a/modules/feh/files/i3/50-feh.conf b/modules/feh/files/i3/50-feh.conf new file mode 100644 index 0000000..40ac7da --- /dev/null +++ b/modules/feh/files/i3/50-feh.conf @@ -0,0 +1,3 @@ +# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session. +bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg diff --git a/modules/feh/go.mod b/modules/feh/go.mod new file mode 100644 index 0000000..4df3720 --- /dev/null +++ b/modules/feh/go.mod @@ -0,0 +1,5 @@ +module feh + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/feh/go.sum b/modules/feh/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/feh/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/feh/module.json b/modules/feh/module.json new file mode 100644 index 0000000..9853e54 --- /dev/null +++ b/modules/feh/module.json @@ -0,0 +1,71 @@ +{ + "module": "feh", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "tools": [ + "feh_set", + "feh_current" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The wallpaper (module feh, novox/hq ADR 0208): the declared one, set once per session.\n\"$HOME/.fehbg\"\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "feh" + }, + { + "id": "wallpapers", + "type": "archive", + "path": "${machine:account-home}/.local/share/feh/wallpapers", + "owner": "${machine:account}", + "artifact": "wallpapers" + }, + { + "id": "fehbg", + "type": "file", + "path": "${machine:account-home}/.fehbg", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n" + }, + { + "id": "i3-bindings", + "type": "file", + "path": "${machine:account-home}/.config/i3/config.d/50-feh.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n" + } + ], + "build": { + "artifacts": [ + { + "name": "wallpapers", + "kind": "archive", + "from": "wallpaper" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/feh-tools", + "binary": "feh-tools", + "loads": [ + "feh-tools" + ] + } + ] + } +} diff --git a/modules/feh/wallpaper/default.jpg b/modules/feh/wallpaper/default.jpg new file mode 100644 index 0000000..266ad56 Binary files /dev/null and b/modules/feh/wallpaper/default.jpg differ diff --git a/modules/gnome-keyring/README.md b/modules/gnome-keyring/README.md new file mode 100644 index 0000000..bf943e9 --- /dev/null +++ b/modules/gnome-keyring/README.md @@ -0,0 +1,101 @@ +# gnome-keyring + +The secret service as a module (novox/hq ADR 0208, ADR 0102). + +- Installs `gnome-keyring` (it brings `gcr-4`, whose ssh agent this uses), `libsecret` (the client + library and `secret-tool`) and `seahorse` (the keyrings' manager, for the operator). +- Claims the mesh's `node-secret-service` seat (no verbs yet, ADR 0208 §2). It requires no display: + the secret service is a D-Bus service, and a Wayland session uses the same module. +- **Writes the PAM lines into the stacks, never over them** (ADR 0102). Each is a marked block at the + end of the file; every other line stays the distribution's. + - `/etc/pam.d/login`: `auth optional pam_gnome_keyring.so` and + `session optional pam_gnome_keyring.so auto_start`. The login manager's stack includes `login`, + so the password typed at the login screen unlocks the login keyring, and the login session starts + the daemon. + - `/etc/pam.d/passwd`: `password optional pam_gnome_keyring.so`, so changing the account's password + changes the keyring's, and the next login still unlocks it. +- **Starts no daemon.** PAM starts it at login, and D-Bus would if PAM had not. +- Names gcr's ssh agent socket for the session, in the `xinitrc` slot `first`: `SSH_AUTH_SOCK` is + `$XDG_RUNTIME_DIR/gcr/ssh`. The agent itself is `gcr-ssh-agent.socket`, a user unit the package + enables by preset. + +## Tools + +None reads a secret. They ask the Secret Service for names, counts and lock states, and the agent for +fingerprints. + +| tool | does | +|---|---| +| `gnome_keyring_unlocked` | the login and default keyrings, locked or not; whether the daemon runs | +| `gnome_keyring_lock` | lock a keyring now (login by default); unlocking stays the operator's | +| `gnome_keyring_collections` | every keyring: id, label, locked, item count, created, changed, default | +| `gnome_keyring_ssh_keys` | the agent's keys by fingerprint, size, type and comment | + +## What it improves on what was found + +- **The desktop's login unlocks the keyring.** Its `/etc/pam.d/login` had no keyring lines, so the + keyring stayed locked after every login, and a script prompted for the password to unlock it. Both + workstations now get the same lines. +- **One daemon.** The session's start ran `gnome-keyring-daemon --start` a second time, asking for an + ssh component that gnome-keyring no longer has, and the window manager ran an unlock-prompt script. + Both go. +- **The session has an ssh agent.** The found `export SSH_AUTH_SOCK` exported nothing: the second + daemon printed no socket. The session's processes had no agent, although gcr's was listening. + +## The default keyring is not the login keyring + +On both workstations, measured on 2026-10-04, the default keyring, where programs store new secrets, +is a second keyring, `Default_keyring`. The login keyring holds one item at most. PAM unlocks only the +login keyring. Another keyring opens with it only if its password is stored in the login keyring +("unlock automatically"). On the desktop the login keyring was locked and the default one unlocked, +which the unlock-prompt script did. + +After the first login with this module: `gnome_keyring_unlocked` shows both. If the default keyring +is still locked, choose one, once, in `seahorse`: + +- tick its *unlock automatically* when prompted; +- or move its items into the login keyring and make that the default. + +Which keyring is the default is the operator's data, never the module's. + +## Blockers and a proposal + +**`SSH_AUTH_SOCK` belongs in the account's environment, and ADR 0203 cannot say it yet.** The value +is a path under the account's runtime directory (`/run/user/`). ADR 0203 forbids `$` in a +contributed value, and no `${machine:…}` fact names that directory. So today the variable reaches +only the X session and what it starts, through the `xinitrc` slot. An ssh login, the login shell's +`execute` and the user manager's services do not get it. + +**Proposed:** a machine fact `${machine:account-runtime-dir}`, resolved like `${machine:account-home}` +from the account's uid. The variable then becomes an environment contribution: + +> `environment.variables.SSH_AUTH_SOCK` = `${machine:account-runtime-dir}/gcr/ssh` + +The slot contribution then goes. That is a progressive insight on ADR 0203, or a small record of its +own. It changes the controller's machine facts, not this module's shape. + +**User-scoped units (mesh-host #72).** `gcr-ssh-agent.socket` and `gnome-keyring-daemon.socket` are +enabled by the package's presets on both workstations, and nothing in the mesh asserts it. Once user +units ship, this module should declare both enabled. + +## What it leaves as found + +- The keyrings themselves (`~/.local/share/keyrings/`): the operator's data, never touched. +- `~/.config/i3/unlock-keyring.sh`, the unlock-prompt script. + +## Migration (ADR 0182) + +1. **On the laptop,** `/etc/pam.d/login` already has the two lines outside any block. After the first + push they are there twice. Delete the two hand-written ones, outside the `# BEGIN mesh` block. +2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc` the + `eval $(/usr/bin/gnome-keyring-daemon --start …)` line and the `export SSH_AUTH_SOCK` after it. +3. Once the `i3` module carries the main configuration, its `exec … unlock-keyring.sh` line is gone. + Delete `~/.config/i3/unlock-keyring.sh`. +4. **On the desktop,** log in again after the first push. The keyring is unlocked by the login from + then on. + +## Blockers + +- `node-secret-service` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, + `mctl` reads them as unknown. +- The environment fact above, and user-scoped units (mesh-host #72). diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/args.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-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/gnome-keyring/cmd/gnome-keyring-tools/keyring.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring.go new file mode 100644 index 0000000..04d9149 --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring.go @@ -0,0 +1,260 @@ +package main + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + service = "org.freedesktop.secrets" + servicePath = "/org/freedesktop/secrets" + collectionDir = "/org/freedesktop/secrets/collection/" + busTimeout = 10 * time.Second +) + +// busctl runs one busctl call on the account's session bus and answers its JSON. +func busctl(s Session, args ...string) (json.RawMessage, error) { + r, err := s.run(busTimeout, "", "busctl", append([]string{"--user", "--json=short"}, args...)...) + if err != nil { + return nil, err + } + if r.Code != 0 { + return nil, fmt.Errorf("the secret service: %s", strings.TrimSpace(r.Stderr)) + } + var v struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal([]byte(r.Stdout), &v); err != nil { + return nil, fmt.Errorf("busctl answered no JSON: %w", err) + } + return v.Data, nil +} + +// collectionID is the part of a collection's object path after .../collection/, unescaped the way +// the Secret Service escapes it ("_5f" is "_"). +func collectionID(path string) string { return strings.TrimPrefix(path, collectionDir) } + +func collectionPath(id string) string { return collectionDir + id } + +var validID = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +// Collection is one keyring. +type Collection struct { + ID string `json:"id"` + Label string `json:"label"` + Locked bool `json:"locked"` + Items int `json:"items"` + Created string `json:"created,omitempty"` + Modified string `json:"modified,omitempty"` + Default bool `json:"default,omitempty"` +} + +// CollectionsResult is what gnome_keyring_collections answers. +type CollectionsResult struct { + Collections []Collection `json:"collections"` +} + +func paths(s Session) ([]string, error) { + raw, err := busctl(s, "get-property", service, servicePath, "org.freedesktop.Secret.Service", "Collections") + if err != nil { + return nil, err + } + var out []string + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the collections: %w", err) + } + return out, nil +} + +func defaultCollection(s Session) string { + raw, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "ReadAlias", "s", "default") + if err != nil { + return "" + } + var out []string + if json.Unmarshal(raw, &out) != nil || len(out) == 0 || out[0] == "/" { + return "" + } + return collectionID(out[0]) +} + +// describe reads a collection's properties: label, lock, the number of items (never the items), times. +func describe(s Session, path string) (Collection, error) { + raw, err := busctl(s, "call", service, path, "org.freedesktop.DBus.Properties", "GetAll", "s", "org.freedesktop.Secret.Collection") + if err != nil { + return Collection{}, err + } + return parseCollection(path, raw) +} + +func parseCollection(path string, raw json.RawMessage) (Collection, error) { + var answer []map[string]struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &answer); err != nil || len(answer) != 1 { + return Collection{}, fmt.Errorf("collection %s: not a property map", path) + } + p := answer[0] + c := Collection{ID: collectionID(path)} + _ = json.Unmarshal(p["Label"].Data, &c.Label) + _ = json.Unmarshal(p["Locked"].Data, &c.Locked) + var items []string + _ = json.Unmarshal(p["Items"].Data, &items) + c.Items = len(items) + for key, into := range map[string]*string{"Created": &c.Created, "Modified": &c.Modified} { + var t int64 + if json.Unmarshal(p[key].Data, &t) == nil && t > 0 { + *into = time.Unix(t, 0).Format(time.RFC3339) + } + } + return c, nil +} + +// Collections are the operator's keyrings. +func Collections() (CollectionsResult, error) { + s, err := findBus() + if err != nil { + return CollectionsResult{}, err + } + ps, err := paths(s) + if err != nil { + return CollectionsResult{}, err + } + def := defaultCollection(s) + out := CollectionsResult{Collections: []Collection{}} + for _, p := range ps { + c, err := describe(s, p) + if err != nil { + return CollectionsResult{}, err + } + c.Default = c.ID == def + out.Collections = append(out.Collections, c) + } + sort.Slice(out.Collections, func(i, j int) bool { return out.Collections[i].ID < out.Collections[j].ID }) + return out, nil +} + +// UnlockedResult is what gnome_keyring_unlocked answers. +type UnlockedResult struct { + Daemon bool `json:"daemon_running"` + Login *Collection `json:"login,omitempty"` + Default *Collection `json:"default,omitempty"` + Note string `json:"note,omitempty"` +} + +// Unlocked is whether the login and default keyrings are unlocked. +func Unlocked() (UnlockedResult, error) { + s, err := findBus() + if err != nil { + return UnlockedResult{}, err + } + // gnome-keyring-daemon, as the kernel shortens a command's name to 15 characters. + out := UnlockedResult{Daemon: len(processesOf("gnome-keyring-d")) > 0} + if c, err := describe(s, collectionPath("login")); err == nil { + out.Login = &c + } else { + out.Note = "no login keyring: " + err.Error() + } + if def := defaultCollection(s); def != "" && def != "login" { + if c, err := describe(s, collectionPath(def)); err == nil { + c.Default = true + out.Default = &c + } + } else if out.Login != nil { + out.Login.Default = def == "login" + } + return out, nil +} + +// LockResult is what gnome_keyring_lock answers. +type LockResult struct { + Collection string `json:"collection"` + Locked bool `json:"locked"` +} + +// Lock locks one keyring. +func Lock(id string) (LockResult, error) { + if id == "" { + id = "login" + } + if !validID.MatchString(id) { + return LockResult{}, fmt.Errorf("collection %q is not a keyring id", id) + } + s, err := findBus() + if err != nil { + return LockResult{}, err + } + if _, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "Lock", "ao", "1", collectionPath(id)); err != nil { + return LockResult{}, err + } + c, err := describe(s, collectionPath(id)) + if err != nil { + return LockResult{}, err + } + return LockResult{Collection: id, Locked: c.Locked}, nil +} + +// Key is one key the ssh agent holds. +type Key struct { + Bits int `json:"bits"` + Fingerprint string `json:"fingerprint"` + Comment string `json:"comment"` + Type string `json:"type"` +} + +// SSHKeysResult is what gnome_keyring_ssh_keys answers. +type SSHKeysResult struct { + Agent string `json:"agent"` + Keys []Key `json:"keys"` + Note string `json:"note,omitempty"` +} + +// agentSocket is gcr's ssh agent socket, which its user socket unit listens on. +func agentSocket(s Session) string { return filepath.Join(s.RuntimeDir, "gcr", "ssh") } + +var keyLine = regexp.MustCompile(`^(\d+)\s+(\S+)\s+(.*?)\s*\(([A-Z0-9-]+)\)$`) + +func parseKeys(out string) []Key { + keys := []Key{} + for _, line := range strings.Split(out, "\n") { + m := keyLine.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil { + continue + } + bits, _ := strconv.Atoi(m[1]) + keys = append(keys, Key{Bits: bits, Fingerprint: m[2], Comment: m[3], Type: m[4]}) + } + return keys +} + +// SSHKeys lists what gcr's ssh agent holds, by fingerprint. +func SSHKeys() (SSHKeysResult, error) { + s, err := findBus() + if err != nil { + return SSHKeysResult{}, err + } + sock := agentSocket(s) + // The agent is named for this one command only; nothing else of the tool's environment changes. + r, err := s.run(busTimeout, "", "env", "SSH_AUTH_SOCK="+sock, "ssh-add", "-l", "-E", "sha256") + if err != nil { + return SSHKeysResult{}, err + } + out := SSHKeysResult{Agent: sock, Keys: parseKeys(r.Stdout)} + switch r.Code { + case 0: + case 1: + out.Note = "the agent holds no keys" + case 127: + return SSHKeysResult{}, fmt.Errorf("ssh-add is not installed on this machine") + default: + return SSHKeysResult{}, fmt.Errorf("the ssh agent at %s does not answer: %s (is gcr-ssh-agent.socket enabled?)", + sock, strings.TrimSpace(r.Stderr)) + } + return out, nil +} diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring_test.go new file mode 100644 index 0000000..b824495 --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const nobody = 4194400 + +// secretService fakes busctl answering as gnome-keyring did on 2026-10-04: a session, a login and a +// default keyring, the login one unlocked; Lock locks it. +func secretService(t *testing.T) string { + t.Helper() + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + 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) + } + bin := fakeBinaries(t, map[string]string{"busctl": `echo "$*" >> "$LOG" +locked=false; [ -f "$LOG.locked" ] && locked=true +case "$*" in + *"get-property org.freedesktop.secrets /org/freedesktop/secrets org.freedesktop.Secret.Service Collections") + echo '{"type":"ao","data":["/org/freedesktop/secrets/collection/session","/org/freedesktop/secrets/collection/login","/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;; + *"ReadAlias s default") echo '{"type":"o","data":["/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;; + *"/collection/login org.freedesktop.DBus.Properties GetAll"*) + echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":["/x/1","/x/2"]},"Label":{"type":"s","data":"Login"},"Locked":{"type":"b","data":'$locked'},"Created":{"type":"t","data":1752488320},"Modified":{"type":"t","data":0}}]}' ;; + *"/collection/"*"GetAll"*) + echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":[]},"Label":{"type":"s","data":"Other"},"Locked":{"type":"b","data":true},"Created":{"type":"t","data":0},"Modified":{"type":"t","data":0}}]}' ;; + *"Lock ao 1 /org/freedesktop/secrets/collection/login") touch "$LOG.locked"; echo '{"type":"aoo","data":[["/org/freedesktop/secrets/collection/login"],"/"]}' ;; + *) echo "no such call: $*" >&2; exit 1 ;; +esac`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + return bin +} + +func TestCollectionsAreNamesCountsAndLocksNeverItems(t *testing.T) { + bin := secretService(t) + got, err := Collections() + if err != nil { + t.Fatal(err) + } + if len(got.Collections) != 3 { + t.Fatalf("%+v", got) + } + var login, def Collection + for _, c := range got.Collections { + switch c.ID { + case "login": + login = c + case "Default_5fkeyring": + def = c + } + } + if login.Label != "Login" || login.Locked || login.Items != 2 || login.Created == "" || login.Modified != "" || login.Default { + t.Fatalf("login: %+v", login) + } + if !def.Default || !def.Locked { + t.Fatalf("default: %+v", def) + } + asked, _ := os.ReadFile(filepath.Join(bin, "log")) + if strings.Contains(string(asked), "GetSecret") || strings.Contains(string(asked), "Item") && strings.Contains(string(asked), "Secret.Item") { + t.Fatalf("a secret was asked for:\n%s", asked) + } +} + +func TestUnlockedAnswersTheLoginAndTheDefaultKeyring(t *testing.T) { + secretService(t) + fakeProcess(t, nobody, "gnome-keyring-d") + got, err := Unlocked() + if err != nil || !got.Daemon || got.Login == nil || got.Login.Locked || got.Default == nil || !got.Default.Default { + t.Fatalf("%+v, %v", got, err) + } +} + +func TestLockLocksTheLoginKeyringAndRefusesAPathForAnId(t *testing.T) { + secretService(t) + got, err := Lock("") + if err != nil || got.Collection != "login" || !got.Locked { + t.Fatalf("%+v, %v", got, err) + } + for _, bad := range []string{"../service", "login /org/x", "a b"} { + if _, err := Lock(bad); err == nil { + t.Errorf("%q was accepted", bad) + } + } +} + +func TestTheAgentsKeysAreFingerprints(t *testing.T) { + keys := parseKeys("256 SHA256:x+LmFabc op@laptop (ED25519)\n3072 SHA256:yyy a comment with spaces (RSA)\nThe agent has no identities.\n") + if len(keys) != 2 || keys[0] != (Key{Bits: 256, Fingerprint: "SHA256:x+LmFabc", Comment: "op@laptop", Type: "ED25519"}) || + keys[1].Comment != "a comment with spaces" || keys[1].Type != "RSA" { + t.Fatalf("%+v", keys) + } +} + +func TestSSHKeysAsksGcrsAgentAndSaysWhenItDoesNotAnswer(t *testing.T) { + secretService(t) + bin := fakeBinaries(t, map[string]string{"ssh-add": `echo "$SSH_AUTH_SOCK $*" > "$LOG.ssh"; [ -f "$NOAGENT" ] && { echo "Could not open a connection" >&2; exit 2; }; echo "256 SHA256:abc op (ED25519)"`}) + t.Setenv("NOAGENT", filepath.Join(bin, "noagent")) + got, err := SSHKeys() + if err != nil || len(got.Keys) != 1 || !strings.HasSuffix(got.Agent, "/gcr/ssh") { + t.Fatalf("%+v, %v", got, err) + } + asked, _ := os.ReadFile(os.Getenv("LOG") + ".ssh") + if !strings.HasSuffix(strings.TrimSpace(string(asked)), "/gcr/ssh -l -E sha256") { + t.Fatalf("asked: %s", asked) + } + if err := os.WriteFile(filepath.Join(bin, "noagent"), nil, 0o644); err != nil { + t.Fatal(err) + } + if _, err := SSHKeys(); err == nil || !strings.Contains(err.Error(), "gcr-ssh-agent.socket") { + t.Fatalf("no agent: %v", err) + } +} + +func TestWithoutABusTheToolsSaySo(t *testing.T) { + fakeMachine(t) + if _, err := Collections(); !errors.Is(err, ErrNoBus) { + t.Fatal(err) + } + if _, err := SSHKeys(); !errors.Is(err, ErrNoBus) { + t.Fatal(err) + } +} diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/main.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/main.go new file mode 100644 index 0000000..941761f --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/main.go @@ -0,0 +1,57 @@ +// gnome-keyring's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the secret service's +// tools, served by the node's runtime as the operator account. node-secret-service has no verbs yet +// (ADR 0208 §2), so every tool here is the module's own. None of them ever reads a secret: they ask the +// Secret Service for names, counts and lock states, and the ssh agent for fingerprints. +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: "gnome_keyring_unlocked", + Description: "Is the operator's keyring unlocked: the login keyring and the default one, each locked " + + "or not, and whether the keyring daemon runs.", + Run: func(map[string]any) (any, error) { return Unlocked() }, + }, + { + Name: "gnome_keyring_lock", + Description: "Lock a keyring now (the login keyring unless another is named): programs must ask " + + "for its password again. Unlocking is the operator's, at their desktop.", + Input: map[string]any{ + "collection": map[string]any{"type": "string", "description": "the keyring's id, as gnome_keyring_collections answers it (default login)"}, + }, + Run: func(args map[string]any) (any, error) { + c, err := text(args, "collection", false) + if err != nil { + return nil, err + } + return Lock(c) + }, + }, + { + Name: "gnome_keyring_collections", + Description: "The operator's keyrings: each one's id, label, whether it is locked, how many items it " + + "holds, when it was created and changed, and which is the default. Never an item, never a secret.", + Run: func(map[string]any) (any, error) { return Collections() }, + }, + { + Name: "gnome_keyring_ssh_keys", + Description: "The keys the session's ssh agent (gcr's) holds, by fingerprint, size, type and comment. " + + "Never a key.", + Run: func(map[string]any) (any, error) { return SSHKeys() }, + }, + } +} diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-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/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go new file mode 100644 index 0000000..67754e0 --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-tools/manifest_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// gnome-keyring's shape (novox/hq ADR 0208, ADR 0102): it claims node-secret-service, writes its PAM +// lines into the login and passwd stacks as marked blocks (never over the files), and starts no daemon +// of its own: PAM and D-Bus do. + +func TestItClaimsTheSecretServiceSeat(t *testing.T) { + m := readManifest(t) + if m.Module != "gnome-keyring" || m.Seats != nil || m.Requires != nil { + t.Fatalf("module %q, seats %v, requires %v", m.Module, m.Seats, m.Requires) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-secret-service", Scope: "node"}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"gnome-keyring", "libsecret", "seahorse"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) { + m := readManifest(t) + for id, want := range map[string]struct{ path, source string }{ + "pam-login": {"/etc/pam.d/login", "files/pam/login"}, + "pam-passwd": {"/etc/pam.d/passwd", "files/pam/passwd"}, + } { + m.sameAsSource(t, id, want.source) + r := m.resource(t, id) + if r["path"] != want.path || r["into"] != "block" || r["at"] != "end" || r["owner"] != nil { + t.Errorf("%s: %v", id, r) + } + } + login := m.resource(t, "pam-login")["content"].(string) + if !strings.Contains(login, "\nauth optional pam_gnome_keyring.so\n") || + !strings.Contains(login, "\nsession optional pam_gnome_keyring.so auto_start\n") { + t.Fatalf("%s", login) + } +} + +func TestItStartsNoDaemonAndOnlyNamesTheAgentsSocket(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "first" { + t.Fatalf("%+v", m.Shell) + } + code := m.Shell[0].Code + if strings.Contains(code, "gnome-keyring-daemon") || strings.Contains(code, "--unlock") || + !strings.Contains(code, `SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"`) { + t.Fatalf("%q", code) + } + if m.Environment != nil { + t.Fatal("SSH_AUTH_SOCK needs the runtime directory, which ADR 0203 forbids in a value; it is not an environment contribution yet") + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/gnome-keyring/cmd/gnome-keyring-tools/session.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-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/gnome-keyring/cmd/gnome-keyring-tools/session_test.go b/modules/gnome-keyring/cmd/gnome-keyring-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/gnome-keyring/cmd/gnome-keyring-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/gnome-keyring/files/pam/login b/modules/gnome-keyring/files/pam/login new file mode 100644 index 0000000..47833d9 --- /dev/null +++ b/modules/gnome-keyring/files/pam/login @@ -0,0 +1,4 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the +# login screen unlocks the keyring, and the login session starts the keyring daemon with it. +auth optional pam_gnome_keyring.so +session optional pam_gnome_keyring.so auto_start diff --git a/modules/gnome-keyring/files/pam/passwd b/modules/gnome-keyring/files/pam/passwd new file mode 100644 index 0000000..dd43bb8 --- /dev/null +++ b/modules/gnome-keyring/files/pam/passwd @@ -0,0 +1,3 @@ +# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's +# password changes the login keyring's with it, so the next login still unlocks it. +password optional pam_gnome_keyring.so diff --git a/modules/gnome-keyring/go.mod b/modules/gnome-keyring/go.mod new file mode 100644 index 0000000..f917a5c --- /dev/null +++ b/modules/gnome-keyring/go.mod @@ -0,0 +1,5 @@ +module gnomekeyring + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/gnome-keyring/go.sum b/modules/gnome-keyring/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/gnome-keyring/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/gnome-keyring/module.json b/modules/gnome-keyring/module.json new file mode 100644 index 0000000..0c796fc --- /dev/null +++ b/modules/gnome-keyring/module.json @@ -0,0 +1,76 @@ +{ + "module": "gnome-keyring", + "version": "1", + "capabilities": [ + "package-manager" + ], + "claims": [ + { + "name": "node-secret-service", + "scope": "node" + } + ], + "tools": [ + "gnome_keyring_unlocked", + "gnome_keyring_lock", + "gnome_keyring_collections", + "gnome_keyring_ssh_keys" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "first", + "code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "gnome-keyring" + }, + { + "id": "library", + "type": "package", + "package": "libsecret" + }, + { + "id": "manager", + "type": "package", + "package": "seahorse" + }, + { + "id": "pam-login", + "type": "file", + "path": "/etc/pam.d/login", + "mode": "0644", + "into": "block", + "at": "end", + "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n" + }, + { + "id": "pam-passwd", + "type": "file", + "path": "/etc/pam.d/passwd", + "mode": "0644", + "into": "block", + "at": "end", + "content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/gnome-keyring-tools", + "binary": "gnome-keyring-tools", + "loads": [ + "gnome-keyring-tools" + ] + } + ] + } +} diff --git a/modules/i3/README.md b/modules/i3/README.md new file mode 100644 index 0000000..489abef --- /dev/null +++ b/modules/i3/README.md @@ -0,0 +1,152 @@ +# i3 + +The window manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 4). + +- **Claims `node-display-session`** and serves its verbs `reload`, `workspaces` and `windows`. +- **Requires `x11-display`**, which only `xorg` on the same machine provides. +- **Packages:** `i3-wm`, and `dex`, which the configuration has always run for XDG autostart. `dex` + was missing on the desktop, so its autostart entries never started there. +- **Environment** (ADR 0203): `XDG_CURRENT_DESKTOP=i3` and `XDG_SESSION_DESKTOP=i3`. They reach + shells, the session (through `xorg`'s block) and the user manager (`environment.d`). The portal + keys off the first. +- **Session code** (ADR 0208 §4): `exec i3 --shmlog-size=26214400` in the `xinitrc` slot `last`. + That is the end of `xorg`'s block in `~/.xinitrc`. + +## What it owns + +| path | class (ADR 0182) | what | +|---|---|---| +| `~/.config/i3/` | owned directory | its contents other than `config` are found and kept | +| `~/.config/i3/config.d/` | owned directory | other modules' drop-ins, and yours | +| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`config/config`](config/config) | +| `/etc/lemurs/wms/i3` | owned | the login manager's session entry: `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`, the session's output in the journal (`journalctl -t x-session`) because the login manager's pipe has no reader | + +**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/-.conf`. The +`include` is the last line of the main file, so every variable set there (`$mod`, `$ws1`…`$ws10`) is +in scope. Files are read in name order. A file of yours there is yours. + +## The reload watcher + +The bundle runs the watcher for as long as the runtime runs it (ADR 0198). It replaces the +predecessor's `i3-reload-watcher` script and its user unit, so this needs **no user-scoped unit**: + +- every 2 s it looks at `config` and `config.d/*.conf` (size and time); +- at its start, if the files differ from what the running i3 loaded, that counts as a change; +- after a change, once the files have been still for one more interval, it checks the result with + `i3 -C`; +- it **reloads only a configuration without errors**. Otherwise it keeps the running one and records + the errors. + +What it has done is in the answers of `reload` and `i3_config_check`. It polls rather than using +inotify, so a file replaced by rename and a directory created later are seen without a fresh watch. +It reloads i3 only. Re-running the bar, the compositor and the notifier is each of those modules' +own business. + +## Tools + +| tool | | what | +|---|---|---| +| `node-display-session.reload` | a | checks, then reloads, keeping windows. Refused with the errors when the check fails; `force` reloads anyway | +| `node-display-session.workspaces` | r | number, name, output, visible, focused, urgent | +| `node-display-session.windows` | r | container id, X id, class, instance, role, title, workspace, output, focused, urgent, floating, fullscreen, scratchpad, marks; narrowed to one workspace | +| `i3_focus` | d | a window by criteria, or a workspace | +| `i3_move` | d | windows to a workspace or output; a workspace to an output | +| `i3_layout_save` | d | a workspace's arrangement as a named layout of placeholders (class, instance, role), in `~/.local/state/mesh/i3/layouts/` | +| `i3_layout_restore` | d | lays a saved layout back; `name: list` lists them | +| `i3_exec` | d | starts a program as i3's child, optionally on a workspace | +| `i3_kill` | d | closes the matching windows, or the focused one when asked; with no arguments it closes nothing | +| `i3_bindings` | r | every binding by mode, with its command and file, from what i3 loaded | +| `i3_config_check` | r | `i3 -C` on the files on disk (errors with file and line), the files i3 loaded, the watcher's record | +| `i3_marks` | r | each mark and its window | +| `i3_scratchpad` | r/d | list, show or move into the scratchpad | + +The tools speak i3's IPC on its socket in the account's runtime directory, and need no display. Every +value a caller gives is quoted before it reaches an i3 command. With no i3 running, the answer is +`{"error": "no-graphical-session", …}`. The bundle's binary is `i3-tools`, so nothing that looks for +`i3` by name finds it. + +## What it improves over today's configuration + +Both workstations ran the same configuration. These changes are against it: + +- **dead:** `exec lxpolkit` (installed on neither machine), the unused `$refresh_i3status`, and the + predecessor's `rice_set` comment; +- **duplicates:** + - `exec xdg-desktop-portal` (it is D-Bus activated); + - `exec xrdb -merge ~/.Xresources` (`xorg`'s session start merges it); + - the `picom`, `nm-applet`, `blueman-applet` and `nextcloud` execs. Each also has an XDG autostart + entry that `dex` starts, and both machines carry those entries; +- **font:** `JetBrainsMono Nerd Font 11` for titles, replacing Hack (research 026/04); +- **terminal:** `$mod+Return` runs `i3-sensible-terminal`, which starts whatever `$TERMINAL` names + (the terminal module's contribution), instead of naming xterm; +- **reloads:** the watcher never reloads into a broken configuration. + +**Moved to their own modules' drop-ins** (the companion modules of research 026). These leave this +file because each module carries them now: + +| lines | now in | +|---|---| +| the launcher bindings (`$mod+d`, `$mod+t`, `$mod+Shift+t`) and the power menu (`$mod+Escape`) | `rofi`'s `50-rofi.conf`, which also adds `$mod+Shift+w` | +| the greenclip daemon and its menu (`$mod+period`) | `clipmenu`'s `50-clipmenu.conf` and its session line | +| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` | +| both bars | `i3status-rust`'s `60-i3status-rust.conf` | +| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login | + +The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables, +and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here, +because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDropInLoadTogether` +loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one +key. + +**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the +operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each +leaves when the module that owns it is written. + +## What it leaves found + +`~/.config/i3/scripts/`, `~/.config/i3/unlock-keyring.sh`, every file in `config.d/`, the +`/usr/share/xsessions` entries (the package's, no longer offered by `lemurs`), and i3's restart +state and logs. + +## The one-off migration (ADR 0182) + +1. **Before the push that assigns `i3`:** do `xorg`'s migration (its README). From that push on, your + lines below `xorg`'s block in `~/.xinitrc` no longer run. +2. **Disable the predecessor's watcher:** `systemctl --user disable --now i3-reload-watcher.service`. + Then remove `~/.config/systemd/user/i3-reload-watcher.service` and `~/scripts/i3-reload-watcher`. + One thing reloads i3 now. Kept running, it would also `i3-msg restart` on every change, without + checking first. **Keep `i3-bar-watchdog.service` as it is**: the bar is `i3status-rust`'s, and that + module decides. +3. **Delete `/etc/lemurs/wms/i3wm`** (see `lemurs`). +4. **`config.d/` fragments:** + - `10-asus.conf` and `20-g14.conf` (the laptop) belong to that machine model's hardware module. + Keep them until it exists. The desktop no longer carries them. + - `50-slack.conf` (both) is yours, or a future `slack` module's. Keep it. + - `99-tmp-wine-focus.conf` (the desktop), a test of a predecessor change by its own comment: + **delete** it, or keep it as yours. +5. **The main file's predecessor copy** is kept once by the host and written over. Nothing to do. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| packages | none (`i3-wm` and `dex` present) | `dex` installed | +| `~/.config/i3/config` | written: the improved configuration | the same | +| running i3 | **the watcher reloads it once the file changes**. i3 keeps every window, and the title font becomes JetBrains Mono. **Assigned without `rofi`, `clipmenu`, `feh` and `i3status-rust`, the reload takes away the bars and those keys** until they are assigned too, so assign them in the same push. The dropped duplicate execs end nothing that runs | the same | +| `/etc/lemurs/wms/i3` | new: offered as `i3` at the next boot | the same | +| `~/.xinitrc` | `xorg`'s block now ends in `exec i3`: the lines below it stop running at the next login | the same | +| `~/.config/environment.d/50-mesh.conf`, `environment.sh` | gain `XDG_CURRENT_DESKTOP=i3`, `XDG_SESSION_DESKTOP=i3` | the same. Its user manager lacked them, and gets them at the next login | +| next login | XDG autostart as before | **XDG autostart runs for the first time**: Slack, JetBrains Toolbox, Nextcloud, the FortiClient tray, the print applet, the geoclue demo agent and snap's user daemon start from their entries | + +**One reload on assignment is the one live change.** To avoid it, assign during a session you are +about to end, or accept it: a reload keeps every window and workspace. + +## Blockers + +- **`fonts` first** (to-be 42 orders it first). Without `ttf-jetbrains-mono-nerd`, i3's titles and + `i3status-rust`'s bars fall back to pango's default face. On 2026-10-04 the laptop had the package, and the desktop + only a hand-copied file of the face. +- **None for the watcher.** The runtime restarts with every push that changes it, after the push has + written the files. So at its start the watcher compares the files on disk with what the running i3 + loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its + configuration, is therefore reloaded although it also restarted the watcher. diff --git a/modules/i3/cmd/i3-tools/config.go b/modules/i3/cmd/i3-tools/config.go new file mode 100644 index 0000000..4ca05be --- /dev/null +++ b/modules/i3/cmd/i3-tools/config.go @@ -0,0 +1,286 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// Binding is one key binding of the configuration in force. +type Binding struct { + Mode string `json:"mode"` + Kind string `json:"kind"` // bindsym or bindcode + Keys string `json:"keys"` + Command string `json:"command"` + Release bool `json:"release,omitempty"` + File string `json:"file"` +} + +var modeOpen = regexp.MustCompile(`^mode\s+(?:--pango_markup\s+)?("(?:[^"\\]|\\.)*"|\S+)\s*\{$`) + +// Bindings reads the bindings out of configuration text whose variables i3 has already replaced +// (GET_CONFIG's variable_replaced_contents), mode blocks included. +func Bindings(file, text string) []Binding { + var out []Binding + mode, depth := "default", 0 + sc := bufio.NewScanner(strings.NewReader(joinContinued(text))) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if m := modeOpen.FindStringSubmatch(line); m != nil { + if s, err := strconv.Unquote(m[1]); err == nil { + mode = s + } else { + mode = m[1] + } + depth = 1 + continue + } + if depth > 0 { + depth += strings.Count(line, "{") - strings.Count(line, "}") + if depth <= 0 { + mode, depth = "default", 0 + continue + } + } + f := strings.Fields(line) + if len(f) < 3 || (f[0] != "bindsym" && f[0] != "bindcode") { + continue + } + b := Binding{Mode: mode, Kind: f[0], File: file} + i := 1 + for ; i < len(f) && strings.HasPrefix(f[i], "--"); i++ { + if f[i] == "--release" { + b.Release = true + } + } + if i >= len(f)-1 { + continue + } + b.Keys = f[i] + b.Command = strings.Join(f[i+1:], " ") + out = append(out, b) + } + return out +} + +func joinContinued(text string) string { + return strings.ReplaceAll(text, "\\\n", " ") +} + +// ConfigError is one problem `i3 -C` reports. +type ConfigError struct { + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Text string `json:"text"` +} + +var ( + checkError = regexp.MustCompile(`ERROR: (?:CONFIG: )?(.*)$`) + checkFile = regexp.MustCompile(`^\(in file (.+)\)$`) + checkLine = regexp.MustCompile(`^Line\s+(\d+): (.*)$`) + checkMark = regexp.MustCompile(`^\s*\^+\s*$`) +) + +// ParseCheck reads what `i3 -C` printed: each error with the file and line it points at. i3 prints +// the lines around an error as context; the one marked with carets beneath is the error's. +func ParseCheck(text string) []ConfigError { + var out []ConfigError + var cur *ConfigError + file := "" + lastLine, lastText := 0, "" + for _, raw := range strings.Split(text, "\n") { + m := checkError.FindStringSubmatch(raw) + if m == nil { + continue + } + msg := m[1] + switch { + case checkFile.MatchString(msg): + file = checkFile.FindStringSubmatch(msg)[1] + if cur != nil && cur.File == "" { + cur.File = file + } + case checkLine.MatchString(msg): + lm := checkLine.FindStringSubmatch(msg) + lastLine, _ = strconv.Atoi(lm[1]) + lastText = lm[2] + case checkMark.MatchString(msg): + if cur != nil { + cur.Line = lastLine + cur.Text = strings.TrimSpace(cur.Text + " — at: " + strings.TrimSpace(lastText)) + } + default: + out = append(out, ConfigError{File: file, Text: msg}) + cur = &out[len(out)-1] + } + } + return out +} + +// Watched is the configuration's files the reload watcher looks at: the main file and every drop-in. +func Watched(dir string) map[string]string { + out := map[string]string{} + paths := []string{filepath.Join(dir, "config")} + drop, _ := filepath.Glob(filepath.Join(dir, "config.d", "*.conf")) + paths = append(paths, drop...) + for _, p := range paths { + if info, err := os.Stat(p); err == nil { + out[p] = strconv.FormatInt(info.Size(), 10) + "@" + strconv.FormatInt(info.ModTime().UnixNano(), 10) + } + } + return out +} + +// Watcher reloads i3 when its configuration changes on disk, after checking it (ADR 0198: the +// module's own long-running code, inside its tools bundle). It replaces the predecessor's inotify +// script and user unit: it polls, so a file replaced by rename is seen as surely as one written in +// place, and a directory that appears later (config.d) is picked up without a new watch. +// +// **It never reloads into a broken configuration.** i3 would load what it can and show its error bar; +// the watcher instead keeps the running configuration and records why. +type Watcher struct { + Dir string + Every time.Duration + Check func() ([]ConfigError, error) + Reload func() error + // Loaded is what the running i3 loaded, by file (GET_CONFIG). At the watcher's start, a file on + // disk that differs from it — or a drop-in added or gone — is a change still to apply: the runtime + // restarts with every push, after the push has written the files, so a watcher that only compared + // the files with themselves would never reload what the push that started it wrote. + Loaded func() (map[string]string, error) + + mu sync.Mutex + status WatcherStatus +} + +// WatcherStatus is what the watcher has done, for the tools to answer. +type WatcherStatus struct { + Since string `json:"since"` + Files int `json:"files_watched"` + LastChange string `json:"last_change,omitempty"` + Changed []string `json:"changed,omitempty"` + LastReload string `json:"last_reload,omitempty"` + Reloads int `json:"reloads"` + Refused []ConfigError `json:"refused,omitempty"` + LastProblem string `json:"last_problem,omitempty"` +} + +// Status is a copy of what the watcher has done. +func (w *Watcher) Status() WatcherStatus { + w.mu.Lock() + defer w.mu.Unlock() + return w.status +} + +// Run watches until stop closes. A change is acted on once the files have been still for one more +// interval, so a push writing several files is one reload. +func (w *Watcher) Run(stop <-chan struct{}) { + seen := Watched(w.Dir) + w.mu.Lock() + w.status.Since = time.Now().Format(time.RFC3339) + w.status.Files = len(seen) + w.mu.Unlock() + pending := w.stale(seen) + tick := time.NewTicker(w.Every) + defer tick.Stop() + for { + select { + case <-stop: + return + case <-tick.C: + } + now := Watched(w.Dir) + changed := diff(seen, now) + seen = now + if len(changed) > 0 { + pending = true + w.mu.Lock() + w.status.LastChange = time.Now().Format(time.RFC3339) + w.status.Changed = changed + w.status.Files = len(now) + w.mu.Unlock() + continue + } + if !pending { + continue + } + pending = false + w.act() + } +} + +func (w *Watcher) act() { + problems, err := w.Check() + w.mu.Lock() + defer w.mu.Unlock() + switch { + case err != nil: + w.status.LastProblem = "the configuration could not be checked: " + err.Error() + return + case len(problems) > 0: + w.status.Refused = problems + w.status.LastProblem = "not reloaded: the configuration has errors" + return + } + w.status.Refused = nil + w.mu.Unlock() + err = w.Reload() + w.mu.Lock() + if err != nil { + w.status.LastProblem = "reload: " + err.Error() + return + } + w.status.LastProblem = "" + w.status.Reloads++ + w.status.LastReload = time.Now().Format(time.RFC3339) +} + +// stale is whether the files on disk are not what the running i3 loaded. +func (w *Watcher) stale(onDisk map[string]string) bool { + if w.Loaded == nil { + return false + } + loaded, err := w.Loaded() + if err != nil { + return false + } + if len(loaded) != len(onDisk) { + return true + } + for path := range onDisk { + text, ok := loaded[path] + if !ok { + return true + } + disk, err := os.ReadFile(path) + if err != nil || string(disk) != text { + return true + } + } + return false +} + +func diff(a, b map[string]string) []string { + var out []string + for k, v := range b { + if a[k] != v { + out = append(out, k) + } + } + for k := range a { + if _, ok := b[k]; !ok { + out = append(out, k) + } + } + sort.Strings(out) + return out +} diff --git a/modules/i3/cmd/i3-tools/i3_test.go b/modules/i3/cmd/i3-tools/i3_test.go new file mode 100644 index 0000000..add7c5e --- /dev/null +++ b/modules/i3/cmd/i3-tools/i3_test.go @@ -0,0 +1,371 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "i3/internal/desktop" +) + +// fakeI3 answers i3's IPC on a socket of its own: a fixed tree, workspaces and config, and every +// RUN_COMMAND recorded and answered with success unless it contains "nonsense". +type fakeI3 struct { + path string + mu sync.Mutex + commands []string +} + +const tree = `{"id":1,"type":"root","name":"root","nodes":[ + {"id":2,"type":"output","name":"__i3","nodes":[{"id":3,"type":"con","name":"content","nodes":[ + {"id":4,"type":"workspace","name":"__i3_scratch","nodes":[],"floating_nodes":[ + {"id":5,"type":"floating_con","floating":"user_on","nodes":[{"id":6,"type":"con","name":"notes","window":101,"window_properties":{"class":"XTerm","instance":"notes"},"scratchpad_state":"fresh"}]}]}]}]}, + {"id":10,"type":"output","name":"eDP-1","nodes":[ + {"id":11,"type":"dockarea","name":"topdock","nodes":[{"id":12,"type":"con","name":"i3bar for output eDP-1","window":200,"window_properties":{"class":"i3bar"}}]}, + {"id":13,"type":"con","name":"content","nodes":[ + {"id":20,"type":"workspace","name":"1","layout":"splith","nodes":[ + {"id":21,"type":"con","name":"Mail - Thunderbird","layout":"splith","percent":0.6,"border":"pixel","current_border_width":1,"floating":"auto_off","window":301,"window_properties":{"class":"thunderbird","instance":"Mail","window_role":"3pane"},"marks":["mail"]}, + {"id":22,"type":"con","name":null,"layout":"splitv","percent":0.4,"border":"pixel","floating":"auto_off","nodes":[ + {"id":23,"type":"con","name":"op: ~ (main)","window":302,"focused":true,"window_properties":{"class":"XTerm","instance":"xterm"}}, + {"id":24,"type":"con","name":"a.b (c)","window":303,"window_properties":{"class":"Foo.Bar","instance":"x"}}]}]}]}]}]}` + +func startFake(t *testing.T) *fakeI3 { + dir, err := os.MkdirTemp("", "i3ipc") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + f := &fakeI3{path: filepath.Join(dir, "ipc")} + l, err := net.Listen("unix", f.path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + go func() { + for { + c, err := l.Accept() + if err != nil { + return + } + go f.serve(c) + } + }() + return f +} + +func (f *fakeI3) serve(c net.Conn) { + defer c.Close() + head := make([]byte, 14) + if _, err := io.ReadFull(c, head); err != nil { + return + } + n := binary.LittleEndian.Uint32(head[6:]) + kind := binary.LittleEndian.Uint32(head[10:]) + body := make([]byte, n) + io.ReadFull(c, body) + var reply string + switch kind { + case RunCommand: + f.mu.Lock() + f.commands = append(f.commands, string(body)) + f.mu.Unlock() + if strings.Contains(string(body), "nonsense") { + reply = `[{"success":false,"error":"Expected one of these tokens"}]` + } else { + reply = `[{"success":true}]` + } + case GetWorkspaces: + reply = `[{"num":1,"name":"1","output":"eDP-1","visible":true,"focused":true,"urgent":false}]` + case GetTree: + reply = tree + case GetVersion: + reply = `{"human_readable":"4.25.1","loaded_config_file_name":"/c/config","included_config_file_names":["/c/config.d/50-x.conf"]}` + case GetConfig: + reply = `{"config":"x","included_configs":[{"path":"/c/config","variable_replaced_contents":"bindsym Mod4+Return exec i3-sensible-terminal\nmode \"resize\" {\n bindsym h resize shrink width 10 px\n bindsym Escape mode \"default\"\n}\nbindsym --release Control+Shift+x exec shot\n"},{"path":"/c/config.d/50-x.conf","variable_replaced_contents":"bindcode 133 exec rofi\n"}]}` + } + out := make([]byte, 14+len(reply)) + copy(out, "i3-ipc") + binary.LittleEndian.PutUint32(out[6:], uint32(len(reply))) + binary.LittleEndian.PutUint32(out[10:], kind) + copy(out[14:], reply) + c.Write(out) +} + +func (f *fakeI3) ran() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.commands...) +} + +func withFake(t *testing.T) (*i3, *fakeI3) { + f := startFake(t) + dir := t.TempDir() + return &i3{ + d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }}, + socket: func() (string, error) { return f.path, nil }, + configDir: dir, layouts: filepath.Join(dir, "layouts"), + }, f +} + +func run(t *testing.T, x *i3, tool string, args map[string]any) (any, error) { + for _, tl := range tools(x) { + if tl.Name == tool { + if args == nil { + args = map[string]any{} + } + return tl.Run(args) + } + } + t.Fatalf("no tool %s", tool) + return nil, nil +} + +func asJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func TestWindowsAreTheTreesWindowsWithTheirWorkspaceAndNotTheBars(t *testing.T) { + var root Node + if err := json.Unmarshal([]byte(tree), &root); err != nil { + t.Fatal(err) + } + w := Windows(root) + if len(w) != 4 { + t.Fatalf("%d windows: %+v", len(w), w) + } + if w[0].Title != "notes" || !w[0].Scratchpad || !w[0].Floating || w[0].Workspace != "__i3_scratch" { + t.Fatalf("the scratchpad's window: %+v", w[0]) + } + if w[1].Class != "thunderbird" || w[1].Workspace != "1" || w[1].Output != "eDP-1" || w[1].Window != "0x12d" || w[1].Marks[0] != "mail" { + t.Fatalf("%+v", w[1]) + } + if !w[2].Focused { + t.Fatalf("%+v", w[2]) + } +} + +func TestTheSeatsVerbsAnswerFromI3(t *testing.T) { + x, _ := withFake(t) + got, err := run(t, x, "node-display-session.windows", map[string]any{"workspace": "1"}) + if err != nil || len(got.(map[string]any)["windows"].([]Window)) != 3 { + t.Fatalf("%v %v", asJSON(got), err) + } + got, err = run(t, x, "node-display-session.workspaces", nil) + if err != nil || !strings.Contains(asJSON(got), `"focused":true`) { + t.Fatalf("%v %v", got, err) + } +} + +func TestReloadIsRefusedWhenTheConfigurationHasErrors(t *testing.T) { + x, f := withFake(t) + x.d.Run = func(_ context.Context, _ []string, _ []byte, name string, args ...string) desktop.Result { + return desktop.Result{Code: 1, Stderr: "10/04/2026 - ERROR: CONFIG: Expected one of these tokens\n" + + "10/04/2026 - ERROR: CONFIG: (in file /c/config.d/99-x.conf)\n10/04/2026 - ERROR: CONFIG: Line 3: foo bar\n" + + "10/04/2026 - ERROR: CONFIG: ^^^^^^^\n"} + } + got, err := run(t, x, "node-display-session.reload", nil) + if err != nil || got.(map[string]any)["reloaded"] != false || len(f.ran()) != 0 { + t.Fatalf("%v %v %v", asJSON(got), err, f.ran()) + } + e := got.(map[string]any)["errors"].([]ConfigError) + if len(e) != 1 || e[0].File != "/c/config.d/99-x.conf" || e[0].Line != 3 || !strings.Contains(e[0].Text, "foo bar") { + t.Fatalf("%+v", e) + } + if _, err := run(t, x, "node-display-session.reload", map[string]any{"force": true}); err != nil || f.ran()[0] != "reload" { + t.Fatalf("forced: %v %v", err, f.ran()) + } +} + +func TestCommandsQuoteWhatTheCallerGave(t *testing.T) { + x, f := withFake(t) + steps := []struct { + tool string + args map[string]any + want string + }{ + {"i3_focus", map[string]any{"class": `Fire"fox`}, `[class="Fire\"fox"] focus`}, + {"i3_focus", map[string]any{"workspace": "2: mail"}, `workspace "2: mail"`}, + {"i3_move", map[string]any{"con_id": float64(21), "to_workspace": "3"}, `[con_id=21] move container to workspace "3"`}, + {"i3_move", map[string]any{"to_output": "right"}, `move container to output right`}, + {"i3_move", map[string]any{"workspace_to_output": "1", "to_output": "DP-2"}, `[workspace="^1$"] move workspace to output "DP-2"`}, + {"i3_exec", map[string]any{"command": "xterm -e 'a; b'", "workspace": "4"}, `workspace "4"; exec --no-startup-id "xterm -e 'a; b'"`}, + {"i3_kill", map[string]any{"window": "0x12d"}, `[id=301] kill`}, + {"i3_kill", map[string]any{"focused": true, "force": true}, `kill client`}, + {"i3_scratchpad", map[string]any{"action": "show", "mark": "notes"}, `[con_mark="notes"] scratchpad show`}, + {"i3_scratchpad", map[string]any{"action": "move"}, `move scratchpad`}, + } + for i, s := range steps { + if _, err := run(t, x, s.tool, s.args); err != nil { + t.Fatalf("%s: %v", s.tool, err) + } + if got := f.ran()[i]; got != s.want { + t.Errorf("%s: %q, want %q", s.tool, got, s.want) + } + } + for _, refused := range []struct { + tool string + args map[string]any + }{ + {"i3_kill", nil}, {"i3_focus", nil}, {"i3_move", map[string]any{"class": "x"}}, + {"i3_focus", map[string]any{"window": "301"}}, {"i3_layout_save", map[string]any{"name": "../x"}}, + } { + if _, err := run(t, x, refused.tool, refused.args); err == nil { + t.Errorf("%s %v was accepted", refused.tool, refused.args) + } + } + if _, err := run(t, x, "i3_exec", map[string]any{"command": "nonsense"}); err == nil { + t.Fatal("i3's refusal is the tool's") + } +} + +func TestALayoutIsSavedAsPlaceholdersAndLaidBackOnItsWorkspace(t *testing.T) { + x, f := withFake(t) + got, err := run(t, x, "i3_layout_save", map[string]any{"name": "mail"}) + if err != nil { + t.Fatal(err) + } + if m := got.(map[string]any); m["workspace"] != "1" || m["windows"] != 3 { + t.Fatalf("the focused workspace, its three windows: %v", m) + } + b, _ := os.ReadFile(filepath.Join(x.layouts, "mail.json")) + text := string(b) + if SavedWorkspace(text) != "1" || !strings.Contains(text, `"class": "^thunderbird$"`) || !strings.Contains(text, `"class": "^Foo\\.Bar$"`) || + !strings.Contains(text, `"window_role": "^3pane$"`) || !strings.Contains(text, `"layout": "splitv"`) || strings.Contains(text, `"window": `) { + t.Fatalf("%s", text) + } + got, err = run(t, x, "i3_layout_restore", map[string]any{"name": "mail"}) + if err != nil { + t.Fatal(err) + } + if last := f.ran()[len(f.ran())-1]; last != `workspace "1"; append_layout "`+filepath.Join(x.layouts, "mail.json")+`"` { + t.Fatalf("%q", last) + } + got, _ = run(t, x, "i3_layout_restore", map[string]any{"name": "list"}) + if !strings.Contains(asJSON(got), `"name":"mail"`) { + t.Fatalf("%v", asJSON(got)) + } +} + +func TestBindingsAreReadByModeWithTheirFiles(t *testing.T) { + x, _ := withFake(t) + got, err := run(t, x, "i3_bindings", nil) + if err != nil { + t.Fatal(err) + } + b := got.(map[string]any)["bindings"].([]Binding) + if len(b) != 5 { + t.Fatalf("%+v", b) + } + if b[1].Mode != "resize" || b[1].Keys != "h" || b[3].Mode != "default" || !b[3].Release || b[3].Keys != "Control+Shift+x" { + t.Fatalf("%+v", b) + } + if b[4].Kind != "bindcode" || b[4].File != "/c/config.d/50-x.conf" { + t.Fatalf("%+v", b[4]) + } + got, _ = run(t, x, "i3_bindings", map[string]any{"match": "rofi"}) + if len(got.(map[string]any)["bindings"].([]Binding)) != 1 { + t.Fatal("match") + } +} + +func TestWithNoI3RunningTheToolsSaySo(t *testing.T) { + x := &i3{d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }}, + socket: func() (string, error) { return FindSocket(t.TempDir(), "") }, configDir: t.TempDir()} + for _, tool := range []string{"node-display-session.windows", "node-display-session.workspaces", "i3_marks", "i3_bindings"} { + if _, err := run(t, x, tool, nil); !desktop.IsNoSession(err) { + t.Errorf("%s: %v", tool, err) + } + } + got, err := run(t, x, "i3_config_check", nil) + if err != nil || got.(map[string]any)["valid"] != true || got.(map[string]any)["running"] != nil { + t.Fatalf("the check needs no running i3: %v %v", got, err) + } +} + +func TestTheWatcherReloadsOnceAfterAChangeAndNeverIntoErrors(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "config"), []byte("a"), 0o644) + var mu sync.Mutex + reloads, broken := 0, false + w := &Watcher{Dir: dir, Every: 20 * time.Millisecond, + Check: func() ([]ConfigError, error) { + mu.Lock() + defer mu.Unlock() + if broken { + return []ConfigError{{Text: "bad"}}, nil + } + return nil, nil + }, + Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil }, + } + stop := make(chan struct{}) + defer close(stop) + go w.Run(stop) + time.Sleep(60 * time.Millisecond) + os.MkdirAll(filepath.Join(dir, "config.d"), 0o755) + os.WriteFile(filepath.Join(dir, "config.d", "50-x.conf"), []byte("b"), 0o644) + os.WriteFile(filepath.Join(dir, "config"), []byte("aa"), 0o644) + waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return reloads == 1 }) + time.Sleep(100 * time.Millisecond) + mu.Lock() + if reloads != 1 { + t.Fatalf("one reload for one change of two files: %d", reloads) + } + broken = true + mu.Unlock() + os.WriteFile(filepath.Join(dir, "config"), []byte("aaa"), 0o644) + waitFor(t, func() bool { return len(w.Status().Refused) == 1 }) + if s := w.Status(); s.Reloads != 1 || s.Files != 2 || !strings.Contains(s.LastProblem, "not reloaded") { + t.Fatalf("%+v", s) + } +} + +func TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "config") + os.WriteFile(cfg, []byte("new"), 0o644) + for _, c := range []struct { + loaded map[string]string + want int + }{ + {map[string]string{cfg: "old"}, 1}, + {map[string]string{cfg: "new"}, 0}, + {map[string]string{cfg: "new", filepath.Join(dir, "config.d", "gone.conf"): "x"}, 1}, + } { + var mu sync.Mutex + reloads := 0 + w := &Watcher{Dir: dir, Every: 10 * time.Millisecond, + Check: func() ([]ConfigError, error) { return nil, nil }, + Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil }, + Loaded: func() (map[string]string, error) { return c.loaded, nil }, + } + stop := make(chan struct{}) + go w.Run(stop) + time.Sleep(80 * time.Millisecond) + close(stop) + mu.Lock() + if reloads != c.want { + t.Errorf("loaded %v: %d reloads, want %d", c.loaded, reloads, c.want) + } + mu.Unlock() + } +} + +func waitFor(t *testing.T, ok func() bool) { + for i := 0; i < 200; i++ { + if ok() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timed out") +} diff --git a/modules/i3/cmd/i3-tools/ipc.go b/modules/i3/cmd/i3-tools/ipc.go new file mode 100644 index 0000000..0a00f1c --- /dev/null +++ b/modules/i3/cmd/i3-tools/ipc.go @@ -0,0 +1,117 @@ +package main + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "i3/internal/desktop" +) + +// i3's IPC (https://i3wm.org/docs/ipc.html): "i3-ipc", a little-endian length and type, a JSON +// payload; the reply carries the same header. Spoken directly, so the tools need no i3-msg and no +// display — only the socket, which lives under the account's runtime directory. +const ( + RunCommand = 0 + GetWorkspaces = 1 + GetTree = 4 + GetMarks = 5 + GetVersion = 7 + GetConfig = 9 +) + +const magic = "i3-ipc" + +// Ask sends one message to i3 at socket and decodes its reply into out. +func Ask(socket string, kind uint32, payload string, out any) error { + conn, err := net.DialTimeout("unix", socket, 2*time.Second) + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + msg := make([]byte, 14+len(payload)) + copy(msg, magic) + binary.LittleEndian.PutUint32(msg[6:], uint32(len(payload))) + binary.LittleEndian.PutUint32(msg[10:], kind) + copy(msg[14:], payload) + if _, err := conn.Write(msg); err != nil { + return err + } + head := make([]byte, 14) + if _, err := io.ReadFull(conn, head); err != nil { + return fmt.Errorf("i3 did not answer: %w", err) + } + if string(head[:6]) != magic { + return errors.New("the socket's answer is not i3's") + } + n := binary.LittleEndian.Uint32(head[6:]) + if n > 64<<20 { + return fmt.Errorf("an answer of %d bytes", n) + } + body := make([]byte, n) + if _, err := io.ReadFull(conn, body); err != nil { + return err + } + if got := binary.LittleEndian.Uint32(head[10:]); got != kind { + return fmt.Errorf("asked %d, answered %d", kind, got) + } + return json.Unmarshal(body, out) +} + +// FindSocket is the running i3's IPC socket. The session's I3SOCK when its process carries one; +// else the newest `ipc-socket.` under the runtime directory whose i3 is alive and answers. +func FindSocket(runtimeDir, i3sock string) (string, error) { + if i3sock != "" { + if _, err := os.Stat(i3sock); err == nil { + return i3sock, nil + } + } + matches, _ := filepath.Glob(filepath.Join(runtimeDir, "i3", "ipc-socket.*")) + type cand struct { + path string + pid int + } + var alive []cand + for _, m := range matches { + pid, err := strconv.Atoi(strings.TrimPrefix(filepath.Ext(m), ".")) + if err != nil { + continue + } + if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))); err != nil { + continue + } + alive = append(alive, cand{m, pid}) + } + sort.Slice(alive, func(i, j int) bool { return alive[i].pid > alive[j].pid }) + for _, c := range alive { + var v map[string]any + if Ask(c.path, GetVersion, "", &v) == nil { + return c.path, nil + } + } + return "", &desktop.NoSession{ + Reason: "i3 is not running: no live IPC socket", + Looked: []string{filepath.Join(runtimeDir, "i3", "ipc-socket.*")}, + } +} + +// Outcome is one command's result as i3 answers RUN_COMMAND. +type Outcome struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// Quote makes a value safe inside an i3 command: double quotes, with backslashes and quotes escaped. +func Quote(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} diff --git a/modules/i3/cmd/i3-tools/main.go b/modules/i3/cmd/i3-tools/main.go new file mode 100644 index 0000000..a8fd4a8 --- /dev/null +++ b/modules/i3/cmd/i3-tools/main.go @@ -0,0 +1,191 @@ +// i3's tools (novox/hq ADR 0208, research 026/05): node-display-session's verbs `reload`, `workspaces` +// and `windows`, the module's own tools for focus, moving, layouts, exec, kill, bindings, the +// configuration check, marks and the scratchpad — and, running for as long as the bundle does, the +// watcher that reloads i3 when its configuration changes (ADR 0198). +// +// The tools speak i3's IPC on its socket under the account's runtime directory; they need no display. +// With no i3 running they answer that there is no session, as structured data. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "i3/internal/desktop" +) + +func main() { + t := machine() + w := &Watcher{ + Dir: t.configDir, Every: 2 * time.Second, + Check: func() ([]ConfigError, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return t.check(ctx) + }, + Reload: func() error { + _, err := t.command("reload") + return err + }, + Loaded: t.loaded, + } + t.watcher = w + go w.Run(make(chan struct{})) + if err := stdio.Serve("", tools(t)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// machine is the i3 of this machine's operator account. +func machine() *i3 { + home := desktop.Home() + cfg := os.Getenv("XDG_CONFIG_HOME") + if cfg == "" { + cfg = filepath.Join(home, ".config") + } + state := os.Getenv("XDG_STATE_HOME") + if state == "" { + state = filepath.Join(home, ".local", "state") + } + t := &i3{d: desktop.Machine("i3"), configDir: filepath.Join(cfg, "i3"), layouts: filepath.Join(state, "mesh", "i3", "layouts")} + t.socket = func() (string, error) { + runtime := filepath.Join("/run/user", fmt.Sprint(os.Getuid())) + i3sock := "" + if s, err := t.d.Find(); err == nil { + runtime, i3sock = s.RuntimeDir, s.Word("I3SOCK") + } + return FindSocket(runtime, i3sock) + } + return t +} + +func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + return run(ctx, desktop.Args(args)) + } +} + +func tools(t *i3) []stdio.Tool { + return []stdio.Tool{ + { + Name: "node-display-session.reload", + Description: "Reload i3's configuration in place, keeping every window: checked first with i3 -C, and " + + "refused with the errors when it would not load cleanly (force reloads anyway). Answers i3's result " + + "and what the reload watcher has done.", + Input: desktop.Schema(map[string]any{"force": desktop.Flag("reload even when the check finds errors")}), + Run: call(t.reload), + }, + { + Name: "node-display-session.workspaces", + Description: "The session's workspaces: number, name, output, and whether each is visible, focused or urgent.", + Input: desktop.Schema(map[string]any{}), + Run: call(t.workspaces), + }, + { + Name: "node-display-session.windows", + Description: "The session's windows: container id, X id, class, instance, role, title, workspace and output, " + + "and whether each is focused, urgent, floating, fullscreen or in the scratchpad, with its marks. " + + "Narrowed to one workspace when named.", + Input: desktop.Schema(map[string]any{"workspace": desktop.Str("one workspace, by name (optional)")}), + Run: call(t.windows), + }, + { + Name: "i3_focus", + Description: "Focus a window (by con_id, window, class, instance, title or mark) or a workspace (by name; " + + "created if absent, as i3 does).", + Input: desktop.Schema(criteriaProps(map[string]any{"workspace": desktop.Str("the workspace to show")})), + Run: call(t.focus), + }, + { + Name: "i3_move", + Description: "Move windows (the focused one, or those the criteria match) to a workspace or an output; or, " + + "with workspace_to_output, move a whole workspace to an output.", + Input: desktop.Schema(criteriaProps(map[string]any{ + "to_workspace": desktop.Str("the workspace to move to"), + "to_output": desktop.Str("the output to move to (a name, or left/right/up/down)"), + "workspace_to_output": desktop.Str("move this workspace (by name) to to_output instead of a window"), + })), + Run: call(t.move), + }, + { + Name: "i3_layout_save", + Description: "Save a workspace's arrangement (the focused one when none is named) as a named layout: its " + + "containers, splits and shares, each window as a placeholder for the next window of its class, " + + "instance and role. Kept in the account's state directory (~/.local/state/mesh/i3/layouts).", + Input: desktop.Schema(map[string]any{ + "name": desktop.Str("the layout's name"), + "workspace": desktop.Str("the workspace to save (optional; the focused one)"), + }, "name"), + Run: call(t.layoutSave), + }, + { + Name: "i3_layout_restore", + Description: "Lay a saved layout onto a workspace (the one it was saved from, unless named): its placeholders " + + "wait there and swallow matching windows as they open. With layout list, answers the saved layouts.", + Input: desktop.Schema(map[string]any{ + "name": desktop.Str("the layout's name, or list"), + "workspace": desktop.Str("the workspace to lay it on (optional)"), + }, "name"), + Run: call(t.layoutRestore), + }, + { + Name: "i3_exec", + Description: "Start a program in the session, through i3 (so it is the session's child, with the session's " + + "environment, and outlives the call). Optionally on a given workspace.", + Input: desktop.Schema(map[string]any{ + "command": desktop.Str("the command line, as a shell reads it"), + "workspace": desktop.Str("switch to this workspace first (optional)"), + }, "command"), + Run: call(t.exec), + }, + { + Name: "i3_kill", + Description: "Close the windows the criteria match (politely, as the window's close button), or the focused " + + "one when focused is true. Refused with neither, so a call without arguments closes nothing.", + Input: desktop.Schema(criteriaProps(map[string]any{ + "focused": desktop.Flag("close the focused window"), + "force": desktop.Flag("kill the client instead of asking the window to close"), + })), + Run: call(t.kill), + }, + { + Name: "i3_bindings", + Description: "Every key binding in force and what it runs, by mode, from the configuration i3 loaded " + + "(main file and drop-ins, variables replaced), with the file each comes from. Narrowed by a text " + + "found in the keys or the command.", + Input: desktop.Schema(map[string]any{"match": desktop.Str("only bindings whose keys or command contain this (optional)")}), + Run: call(t.bindings), + }, + { + Name: "i3_config_check", + Description: "Check the configuration on disk (main file and every drop-in) with i3 -C, as the next reload " + + "would load it: valid or the errors with file and line; the files i3 loaded last; and what the " + + "reload watcher has done. Needs no running session.", + Input: desktop.Schema(map[string]any{}), + Run: call(t.configCheck), + }, + { + Name: "i3_marks", + Description: "Every mark set on a window, with the window it is on.", + Input: desktop.Schema(map[string]any{}), + Run: call(t.marks), + }, + { + Name: "i3_scratchpad", + Description: "The scratchpad: list its windows; show (toggle) one — the criteria pick it, else i3 cycles; or " + + "move a window (the criteria's, else the focused one) into it.", + Input: desktop.Schema(criteriaProps(map[string]any{ + "action": desktop.Enum("list (default), show or move", "list", "show", "move"), + })), + Run: call(t.scratchpad), + }, + } +} diff --git a/modules/i3/cmd/i3-tools/manifest_test.go b/modules/i3/cmd/i3-tools/manifest_test.go new file mode 100644 index 0000000..2df3ab2 --- /dev/null +++ b/modules/i3/cmd/i3-tools/manifest_test.go @@ -0,0 +1,205 @@ +package main + +// i3's shape (novox/hq ADR 0208): it holds node-display-session and requires the X display on its own +// machine; it contributes the session's exec to the last xinitrc slot and the desktop's identity to +// the environment; it owns the main configuration, which ends by including the drop-in directory, and +// the login manager's session entry, which runs the session's start. + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +type manifest struct { + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + } `json:"claims"` + Seats any `json:"seats"` + Tools []string + Environment struct { + Variables map[string]string `json:"variables"` + } `json:"environment"` + Shell []struct { + For, Slot, Code string + } `json:"shell"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func resource(t *testing.T, m manifest, id string) map[string]any { + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +func TestItHoldsTheSessionSeatOnAMachineWithAnXDisplay(t *testing.T) { + m := readManifest(t) + if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-session" || strings.Join(m.Claims[0].Serves, ",") != "reload,workspaces,windows" { + t.Fatalf("%+v", m.Claims) + } + if strings.Join(m.Requires, ",") != "x11-display" { + t.Fatalf("requires %v", m.Requires) + } + served := map[string]bool{} + for _, tool := range tools(&i3{}) { + served[tool.Name] = true + } + for _, v := range m.Claims[0].Serves { + if !served["node-display-session."+v] { + t.Errorf("the seat's %s is not served", v) + } + } + if len(served) != len(m.Tools)+3 { + t.Fatalf("served %d, listed %d", len(served), len(m.Tools)) + } + for _, n := range m.Tools { + if !served[n] || !strings.HasPrefix(n, "i3_") { + t.Errorf("%s", n) + } + } +} + +func TestItContributesTheSessionsExecLastAndTheDesktopsIdentity(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "last" { + t.Fatalf("%+v", m.Shell) + } + var code []string + for _, l := range strings.Split(strings.TrimSpace(m.Shell[0].Code), "\n") { + if !strings.HasPrefix(l, "#") { + code = append(code, l) + } + } + if len(code) != 1 || !strings.HasPrefix(code[0], "exec i3") { + t.Fatalf("one line, the exec: %q", code) + } + if v := m.Environment.Variables; len(v) != 2 || v["XDG_CURRENT_DESKTOP"] != "i3" || v["XDG_SESSION_DESKTOP"] != "i3" { + t.Fatalf("%v", v) + } +} + +func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testing.T) { + m := readManifest(t) + r := resource(t, m, "config") + raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) + if r["content"] != string(raw) || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil { + t.Fatal("the manifest carries config/config whole, as an owned file") + } + c := string(raw) + lines := strings.Split(strings.TrimSpace(c), "\n") + if lines[len(lines)-1] != "include ~/.config/i3/config.d/*.conf" { + t.Fatal("the drop-ins are read last, with every variable in scope") + } + var lines2 []string + for _, l := range lines { + if !strings.HasPrefix(strings.TrimSpace(l), "#") { + lines2 = append(lines2, l) + } + } + code := strings.Join(lines2, "\n") + for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm", + "exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/", + // carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring + "rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} { + if strings.Contains(code, gone) { + t.Errorf("the configuration still holds %q", gone) + } + } + if !strings.Contains(c, "\nfont pango:JetBrainsMono Nerd Font 11\n") || !strings.Contains(c, "exec --no-startup-id dex --autostart --environment i3") { + t.Fatal("the chosen face for titles; XDG autostart through dex") + } + if !strings.Contains(c, "bindsym $mod+Delete exec --no-startup-id loginctl lock-session") { + t.Fatal("the lock key goes through logind, which the lock screen's module relies on") + } + if i3, err := exec.LookPath("i3"); err == nil { + out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput() + if err != nil || len(ParseCheck(string(out))) > 0 { + t.Fatalf("i3 -C: %v %s", err, out) + } + } +} + +func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) { + m := readManifest(t) + r := resource(t, m, "session") + if r["path"] != "/etc/lemurs/wms/i3" || r["mode"] != "0755" { + t.Fatalf("%v", r) + } + if !strings.Contains(r["content"].(string), `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`) { + t.Fatal("the entry runs the session's start, never i3 bare") + } + pkgs := map[string]bool{} + for _, x := range m.Resources { + if x["type"] == "package" { + pkgs[x["package"].(string)] = true + } + } + if !pkgs["i3-wm"] || !pkgs["dex"] || len(pkgs) != 2 { + t.Fatalf("%v", pkgs) + } + a := m.Build.Artifacts[0] + if a["from"] != "cmd/i3-tools" || a["binary"] != "i3-tools" || a["language"] != "go" { + t.Fatalf("a binary not named i3, so nothing that looks for i3 by name finds the tools: %v", a) + } +} + +// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as +// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses. +func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) { + i3, err := exec.LookPath("i3") + if err != nil { + t.Skip("no i3 here to check with") + } + dir := t.TempDir() + drop := filepath.Join(dir, "config.d") + os.MkdirAll(drop, 0o755) + manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json")) + var found []string + for _, p := range manifests { + raw, _ := os.ReadFile(p) + var m struct { + Resources []map[string]any `json:"resources"` + } + json.Unmarshal(raw, &m) + for _, r := range m.Resources { + path, _ := r["path"].(string) + if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") { + os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644) + found = append(found, filepath.Base(path)) + } + } + } + main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) + text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1) + os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644) + out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput() + if err != nil || len(ParseCheck(string(out))) > 0 { + t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out) + } +} diff --git a/modules/i3/cmd/i3-tools/tools.go b/modules/i3/cmd/i3-tools/tools.go new file mode 100644 index 0000000..0142b69 --- /dev/null +++ b/modules/i3/cmd/i3-tools/tools.go @@ -0,0 +1,428 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "i3/internal/desktop" +) + +type i3 struct { + d desktop.Desk + socket func() (string, error) + configDir string + layouts string + watcher *Watcher +} + +func (t *i3) ask(kind uint32, payload string, out any) error { + sock, err := t.socket() + if err != nil { + return err + } + return Ask(sock, kind, payload, out) +} + +// command runs i3 commands and fails when any of them did. +func (t *i3) command(cmd string) ([]Outcome, error) { + var out []Outcome + if err := t.ask(RunCommand, cmd, &out); err != nil { + return nil, err + } + for _, o := range out { + if !o.Success { + return out, fmt.Errorf("i3 refused %q: %s", cmd, o.Error) + } + } + return out, nil +} + +func (t *i3) tree() (Node, error) { + var root Node + err := t.ask(GetTree, "", &root) + return root, err +} + +// check runs `i3 -C` on the configuration on disk. It needs no display. +func (t *i3) check(ctx context.Context) ([]ConfigError, error) { + res := t.d.Plain(ctx, "i3", "-C", "-c", filepath.Join(t.configDir, "config")) + if res.Code == 127 { + return nil, fmt.Errorf("i3 is not installed here") + } + problems := append([]ConfigError{}, ParseCheck(res.Stdout+"\n"+res.Stderr)...) + if !res.OK() && len(problems) == 0 { + problems = []ConfigError{{Text: strings.TrimSpace(res.Stdout + res.Stderr)}} + } + return problems, nil +} + +func (t *i3) watcherStatus() any { + if t.watcher == nil { + return nil + } + return t.watcher.Status() +} + +func (t *i3) reload(ctx context.Context, a desktop.Args) (any, error) { + force, _, err := a.Bool("force") + if err != nil { + return nil, err + } + problems, err := t.check(ctx) + if err != nil { + return nil, err + } + if len(problems) > 0 && !force { + return map[string]any{"reloaded": false, "errors": problems, "why": "the configuration on disk has errors; fix them, or force"}, nil + } + out, err := t.command("reload") + if err != nil { + return nil, err + } + return map[string]any{"reloaded": true, "i3": out, "errors": problems, "watcher": t.watcherStatus()}, nil +} + +// WorkspaceInfo is one workspace as GET_WORKSPACES answers it. +type WorkspaceInfo struct { + Num int `json:"num"` + Name string `json:"name"` + Output string `json:"output"` + Visible bool `json:"visible"` + Focused bool `json:"focused"` + Urgent bool `json:"urgent"` +} + +func (t *i3) workspaces(ctx context.Context, a desktop.Args) (any, error) { + var ws []WorkspaceInfo + if err := t.ask(GetWorkspaces, "", &ws); err != nil { + return nil, err + } + return map[string]any{"workspaces": ws}, nil +} + +func (t *i3) windows(ctx context.Context, a desktop.Args) (any, error) { + root, err := t.tree() + if err != nil { + return nil, err + } + all := Windows(root) + ws := a.Opt("workspace", "") + out := []Window{} + for _, w := range all { + if ws == "" || w.Workspace == ws { + out = append(out, w) + } + } + return map[string]any{"windows": out}, nil +} + +func (t *i3) focus(ctx context.Context, a desktop.Args) (any, error) { + crit, err := Criteria(a) + if err != nil { + return nil, err + } + var cmd string + switch ws := a.Opt("workspace", ""); { + case crit != "" && ws != "": + return nil, fmt.Errorf("a window or a workspace, not both") + case crit != "": + cmd = crit + " focus" + case ws != "": + cmd = "workspace " + Quote(ws) + default: + return nil, fmt.Errorf("name a window (con_id, window, class, instance, title, mark) or a workspace") + } + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"command": cmd, "i3": out}, nil +} + +var direction = regexp.MustCompile(`^(left|right|up|down|current|primary|next|prev)$`) + +func target(output string) string { + if direction.MatchString(output) { + return output + } + return Quote(output) +} + +func (t *i3) move(ctx context.Context, a desktop.Args) (any, error) { + crit, err := Criteria(a) + if err != nil { + return nil, err + } + toWS, toOut, wsMove := a.Opt("to_workspace", ""), a.Opt("to_output", ""), a.Opt("workspace_to_output", "") + var cmd string + switch { + case wsMove != "": + if toOut == "" { + return nil, fmt.Errorf("workspace_to_output needs to_output") + } + cmd = "[workspace=" + Quote("^"+regexp.QuoteMeta(wsMove)+"$") + "] move workspace to output " + target(toOut) + case toWS != "" && toOut != "": + return nil, fmt.Errorf("to a workspace or to an output, not both") + case toWS != "": + cmd = strings.TrimSpace(crit + " move container to workspace " + Quote(toWS)) + case toOut != "": + cmd = strings.TrimSpace(crit + " move container to output " + target(toOut)) + default: + return nil, fmt.Errorf("name to_workspace or to_output") + } + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"command": cmd, "i3": out}, nil +} + +var layoutName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +func (t *i3) layoutSave(ctx context.Context, a desktop.Args) (any, error) { + name, err := a.Text("name") + if err != nil { + return nil, err + } + if !layoutName.MatchString(name) { + return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore") + } + root, err := t.tree() + if err != nil { + return nil, err + } + ws, ok := Workspace(root, a.Opt("workspace", "")) + if !ok { + return nil, fmt.Errorf("no workspace %q", a.Opt("workspace", "(focused)")) + } + top, windows := Layout(ws) + if windows == 0 { + return nil, fmt.Errorf("workspace %s holds no windows to save", str(ws.Name)) + } + if err := os.MkdirAll(t.layouts, 0o755); err != nil { + return nil, err + } + path := filepath.Join(t.layouts, name+".json") + if err := os.WriteFile(path, []byte(LayoutFile(str(ws.Name), time.Now().Format(time.RFC3339), top)), 0o644); err != nil { + return nil, err + } + return map[string]any{"name": name, "workspace": str(ws.Name), "windows": windows, "path": path}, nil +} + +func (t *i3) savedLayouts() []map[string]string { + files, _ := filepath.Glob(filepath.Join(t.layouts, "*.json")) + sort.Strings(files) + out := []map[string]string{} + for _, f := range files { + b, _ := os.ReadFile(f) + out = append(out, map[string]string{"name": strings.TrimSuffix(filepath.Base(f), ".json"), "workspace": SavedWorkspace(string(b)), "path": f}) + } + return out +} + +func (t *i3) layoutRestore(ctx context.Context, a desktop.Args) (any, error) { + name, err := a.Text("name") + if err != nil { + return nil, err + } + if name == "list" { + return map[string]any{"layouts": t.savedLayouts()}, nil + } + if !layoutName.MatchString(name) { + return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore") + } + path := filepath.Join(t.layouts, name+".json") + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("no saved layout %q (i3_layout_restore with name list shows them)", name) + } + ws := a.Opt("workspace", SavedWorkspace(string(b))) + if ws == "" { + return nil, fmt.Errorf("name the workspace to lay it on") + } + cmd := "workspace " + Quote(ws) + "; append_layout " + Quote(path) + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"layout": name, "workspace": ws, "i3": out, + "then": "each placeholder swallows the next window of its class as it opens; start the programs (i3_exec) to fill them"}, nil +} + +func (t *i3) exec(ctx context.Context, a desktop.Args) (any, error) { + command, err := a.Text("command") + if err != nil { + return nil, err + } + cmd := "exec --no-startup-id " + Quote(command) + if ws := a.Opt("workspace", ""); ws != "" { + cmd = "workspace " + Quote(ws) + "; " + cmd + } + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"started": command, "i3": out, "how": "a child of i3, in the session; i3 does not answer whether the program itself started"}, nil +} + +func (t *i3) kill(ctx context.Context, a desktop.Args) (any, error) { + crit, err := Criteria(a) + if err != nil { + return nil, err + } + focused, _, err := a.Bool("focused") + if err != nil { + return nil, err + } + if crit == "" && !focused { + return nil, fmt.Errorf("name the windows to close, or focused: true") + } + force, _, _ := a.Bool("force") + cmd := strings.TrimSpace(crit + " kill") + if force { + cmd += " client" + } + before, _ := t.tree() + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"command": cmd, "i3": out, "windows_before": len(Windows(before))}, nil +} + +type configReply struct { + Config string `json:"config"` + Included []struct { + Path string `json:"path"` + Raw string `json:"raw_contents"` + Replaced string `json:"variable_replaced_contents"` + } `json:"included_configs"` +} + +// loaded is each file the running i3 loaded, with the text it read. +func (t *i3) loaded() (map[string]string, error) { + var c configReply + if err := t.ask(GetConfig, "", &c); err != nil { + return nil, err + } + out := map[string]string{} + for _, inc := range c.Included { + out[inc.Path] = inc.Raw + } + if len(out) == 0 { + out[filepath.Join(t.configDir, "config")] = c.Config + } + return out, nil +} + +func (t *i3) bindings(ctx context.Context, a desktop.Args) (any, error) { + var c configReply + if err := t.ask(GetConfig, "", &c); err != nil { + return nil, err + } + var all []Binding + if len(c.Included) == 0 { + all = Bindings("(main)", c.Config) + } + for _, inc := range c.Included { + all = append(all, Bindings(inc.Path, inc.Replaced)...) + } + match := strings.ToLower(a.Opt("match", "")) + out := []Binding{} + for _, b := range all { + if match == "" || strings.Contains(strings.ToLower(b.Keys+" "+b.Command), match) { + out = append(out, b) + } + } + return map[string]any{"bindings": out, "from": "the configuration i3 loaded at its last start or reload"}, nil +} + +func (t *i3) configCheck(ctx context.Context, a desktop.Args) (any, error) { + problems, err := t.check(ctx) + if err != nil { + return nil, err + } + answer := map[string]any{"valid": len(problems) == 0, "errors": problems, "config": filepath.Join(t.configDir, "config"), + "on_disk": sortedKeys(Watched(t.configDir)), "watcher": t.watcherStatus()} + var v struct { + Loaded string `json:"loaded_config_file_name"` + Included []string `json:"included_config_file_names"` + Human string `json:"human_readable"` + } + if err := t.ask(GetVersion, "", &v); err == nil { + answer["running"] = map[string]any{"version": v.Human, "loaded": append([]string{v.Loaded}, v.Included...)} + } + return answer, nil +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func (t *i3) marks(ctx context.Context, a desktop.Args) (any, error) { + root, err := t.tree() + if err != nil { + return nil, err + } + type marked struct { + Mark string `json:"mark"` + Window Window `json:"window"` + } + out := []marked{} + for _, w := range Windows(root) { + for _, m := range w.Marks { + out = append(out, marked{m, w}) + } + } + return map[string]any{"marks": out}, nil +} + +func (t *i3) scratchpad(ctx context.Context, a desktop.Args) (any, error) { + action, err := a.OneOf("action", "list", "list", "show", "move") + if err != nil { + return nil, err + } + crit, err := Criteria(a) + if err != nil { + return nil, err + } + switch action { + case "list": + root, err := t.tree() + if err != nil { + return nil, err + } + out := []Window{} + for _, w := range Windows(root) { + if w.Scratchpad { + out = append(out, w) + } + } + return map[string]any{"scratchpad": out}, nil + case "show": + cmd := strings.TrimSpace(crit + " scratchpad show") + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"command": cmd, "i3": out}, nil + default: + cmd := strings.TrimSpace(crit + " move scratchpad") + out, err := t.command(cmd) + if err != nil { + return nil, err + } + return map[string]any{"command": cmd, "i3": out}, nil + } +} diff --git a/modules/i3/cmd/i3-tools/tree.go b/modules/i3/cmd/i3-tools/tree.go new file mode 100644 index 0000000..59c63bc --- /dev/null +++ b/modules/i3/cmd/i3-tools/tree.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + + "i3/internal/desktop" +) + +// Node is a container of i3's tree, as GET_TREE answers it. +type Node struct { + ID int64 `json:"id"` + Type string `json:"type"` + Name *string `json:"name"` + Layout string `json:"layout"` + Orientation string `json:"orientation"` + Percent *float64 `json:"percent"` + Border string `json:"border"` + BorderWidth int `json:"current_border_width"` + Floating string `json:"floating"` + Focused bool `json:"focused"` + Urgent bool `json:"urgent"` + Marks []string `json:"marks"` + Window *int64 `json:"window"` + WindowProperties map[string]any `json:"window_properties"` + Fullscreen int `json:"fullscreen_mode"` + Scratchpad string `json:"scratchpad_state"` + Geometry map[string]int `json:"geometry"` + Rect map[string]int `json:"rect"` + Nodes []Node `json:"nodes"` + FloatingNodes []Node `json:"floating_nodes"` +} + +// Window is one window, as the windows verb answers it. +type Window struct { + ConID int64 `json:"con_id"` + Window string `json:"window"` + Class string `json:"class,omitempty"` + Instance string `json:"instance,omitempty"` + Role string `json:"role,omitempty"` + Title string `json:"title"` + Workspace string `json:"workspace"` + Output string `json:"output"` + Focused bool `json:"focused,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Floating bool `json:"floating,omitempty"` + Fullscreen bool `json:"fullscreen,omitempty"` + Scratchpad bool `json:"scratchpad,omitempty"` + Marks []string `json:"marks,omitempty"` +} + +func str(p *string) string { + if p == nil { + return "" + } + return *p +} + +func prop(n Node, key string) string { + if v, ok := n.WindowProperties[key].(string); ok { + return v + } + return "" +} + +// Windows is every window of the tree with the workspace and output it is on. The scratchpad's +// windows are on the workspace "__i3_scratch" and say so; the bars, in the dock areas, are not windows +// a person arranges and are left out. +func Windows(root Node) []Window { + var out []Window + var walk func(n Node, output, workspace string, dock, floating bool) + walk = func(n Node, output, workspace string, dock, floating bool) { + switch n.Type { + case "output": + output = str(n.Name) + case "workspace": + workspace = str(n.Name) + case "dockarea": + dock = true + case "floating_con": + floating = true + } + if n.Window != nil && !dock { + out = append(out, Window{ + ConID: n.ID, Window: "0x" + strconv.FormatInt(*n.Window, 16), + Class: prop(n, "class"), Instance: prop(n, "instance"), Role: prop(n, "window_role"), + Title: str(n.Name), Workspace: workspace, Output: output, + Focused: n.Focused, Urgent: n.Urgent, Floating: floating || strings.HasSuffix(n.Floating, "_on"), + Fullscreen: n.Fullscreen != 0, Scratchpad: workspace == "__i3_scratch", Marks: n.Marks, + }) + } + for _, c := range n.Nodes { + walk(c, output, workspace, dock, floating) + } + for _, c := range n.FloatingNodes { + walk(c, output, workspace, dock, true) + } + } + walk(root, "", "", false, false) + return out +} + +// Workspace is the subtree of one workspace, by name; or the focused one's when name is empty. +func Workspace(root Node, name string) (Node, bool) { + var found *Node + var walk func(n Node, ws *Node) + walk = func(n Node, ws *Node) { + if found != nil { + return + } + if n.Type == "workspace" { + nn := n + ws = &nn + if name != "" && str(n.Name) == name { + found = ws + return + } + } + if name == "" && n.Focused && ws != nil { + found = ws + return + } + for _, c := range append(append([]Node{}, n.Nodes...), n.FloatingNodes...) { + walk(c, ws) + } + } + walk(root, nil) + if found == nil { + return Node{}, false + } + return *found, true +} + +// Layout is a workspace's arrangement in the form i3's append_layout reads (i3's layout saving +// docs): each container with its layout, share and border, each window replaced by a placeholder that +// swallows the next window matching its class, instance and role. One JSON object per top-level +// container, as i3-save-tree writes them. +func Layout(ws Node) ([]map[string]any, int) { + windows := 0 + var conv func(n Node) map[string]any + conv = func(n Node) map[string]any { + out := map[string]any{"border": n.Border, "current_border_width": n.BorderWidth, "floating": n.Floating, "layout": n.Layout, "type": "con"} + if n.Percent != nil { + out["percent"] = *n.Percent + } + if n.Name != nil { + out["name"] = *n.Name + } + if len(n.Marks) > 0 { + out["marks"] = n.Marks + } + if n.Window != nil { + windows++ + swallow := map[string]string{} + for key, field := range map[string]string{"class": "class", "instance": "instance", "window_role": "window_role"} { + if v := prop(n, key); v != "" { + swallow[field] = "^" + regexp.QuoteMeta(v) + "$" + } + } + out["swallows"] = []map[string]string{swallow} + return out + } + var kids []map[string]any + for _, c := range n.Nodes { + kids = append(kids, conv(c)) + } + if kids != nil { + out["nodes"] = kids + } + return out + } + var top []map[string]any + for _, c := range ws.Nodes { + top = append(top, conv(c)) + } + for _, f := range ws.FloatingNodes { + fc := conv(f) + fc["type"] = "floating_con" + if g := f.Rect; g != nil { + fc["rect"] = g + } + top = append(top, fc) + } + return top, windows +} + +// LayoutFile is a saved layout as written to disk: a comment naming the workspace, then the objects. +func LayoutFile(workspace, saved string, top []map[string]any) string { + var b strings.Builder + fmt.Fprintf(&b, "// mesh i3 layout of workspace %s, saved %s\n", strconv.Quote(workspace), saved) + for _, t := range top { + raw, _ := json.MarshalIndent(t, "", " ") + b.Write(raw) + b.WriteString("\n") + } + return b.String() +} + +var savedWorkspace = regexp.MustCompile(`^// mesh i3 layout of workspace ("(?:[^"\\]|\\.)*")`) + +// SavedWorkspace is the workspace a saved layout was taken from. +func SavedWorkspace(file string) string { + if m := savedWorkspace.FindStringSubmatch(file); m != nil { + if s, err := strconv.Unquote(m[1]); err == nil { + return s + } + } + return "" +} + +// Criteria builds i3's window criteria from a tool's arguments: con_id, window (X id), class, +// instance, title, mark, each quoted. Empty when none is given. +func Criteria(a desktop.Args) (string, error) { + var parts []string + if a.Has("con_id") { + f, _, err := a.Number("con_id") + if err != nil || f <= 0 { + return "", fmt.Errorf("con_id is a container's id, as windows answers it") + } + parts = append(parts, "con_id="+strconv.FormatInt(int64(f), 10)) + } + if w := a.Opt("window", ""); w != "" { + n, err := strconv.ParseInt(strings.TrimPrefix(strings.ToLower(w), "0x"), 16, 64) + if err != nil || !strings.HasPrefix(strings.ToLower(w), "0x") { + return "", fmt.Errorf("window is an X id, e.g. 0x3a00007") + } + parts = append(parts, "id="+strconv.FormatInt(n, 10)) + } + for _, k := range []string{"class", "instance", "title", "con_mark"} { + arg := k + if k == "con_mark" { + arg = "mark" + } + if v := a.Opt(arg, ""); v != "" { + parts = append(parts, k+"="+Quote(v)) + } + } + if len(parts) == 0 { + return "", nil + } + return "[" + strings.Join(parts, " ") + "]", nil +} + +// criteriaProps are the arguments every window-targeting tool takes. +func criteriaProps(extra map[string]any) map[string]any { + p := map[string]any{ + "con_id": desktop.Int("the container's id, as windows answers it"), + "window": desktop.Str("the X window id, e.g. 0x3a00007"), + "class": desktop.Str("windows whose class matches this (a regular expression)"), + "instance": desktop.Str("windows whose instance matches this"), + "title": desktop.Str("windows whose title matches this"), + "mark": desktop.Str("the window holding this mark"), + } + for k, v := range extra { + p[k] = v + } + return p +} diff --git a/modules/i3/config/config b/modules/i3/config/config new file mode 100644 index 0000000..085dcea --- /dev/null +++ b/modules/i3/config/config @@ -0,0 +1,195 @@ +# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; +# change the module instead. i3's user guide is the reference. +# +# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named +# -.conf and read in name order by the include at the end, where every variable set here +# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours. +# +# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the +# result with i3 -C; it never reloads into a configuration with errors. + +# Font for window titles, and the bars below: the mesh's monospace face (research 026/04). +font pango:JetBrainsMono Nerd Font 11 + +# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login. +exec --no-startup-id dex --autostart --environment i3 + +######################################### +###### Keys #### +######################################### +# To find key symbols: xmodmap -pke / xmodmap -pm +set $mod Mod4 +set $alt Mod1 +set $shift Shift +set $ctrl Control + +# use these keys for focus, movement, and resize directions when reaching for +# the arrows is not convenient +set $left h +set $down j +set $up k +set $right l + +# use Mouse+$mod to drag floating windows to their wanted position +floating_modifier $mod + +# move tiling windows via drag & drop by left-clicking into the title bar, +# or left-clicking anywhere into the window while holding the floating modifier. +tiling_drag modifier titlebar + +# start a terminal: whichever the terminal module names in $TERMINAL +bindsym $mod+Return exec i3-sensible-terminal + +# kill focused window +bindsym $mod+$shift+q kill + +# change focus +bindsym $mod+$left focus left +bindsym $mod+$down focus down +bindsym $mod+$up focus up +bindsym $mod+$right focus right + +bindsym $mod+Left focus left +bindsym $mod+Down focus down +bindsym $mod+Up focus up +bindsym $mod+Right focus right + +# move focused window +bindsym $mod+$shift+$left move left +bindsym $mod+$shift+$down move down +bindsym $mod+$shift+$up move up +bindsym $mod+$shift+$right move right + +bindsym $mod+$shift+Left move left +bindsym $mod+$shift+Down move down +bindsym $mod+$shift+Up move up +bindsym $mod+$shift+Right move right + +# split in horizontal orientation +bindsym $mod+c split h +# split in vertical orientation +bindsym $mod+v split v + +# enter fullscreen mode for the focused container +bindsym $mod+f fullscreen toggle + +# change container layout (stacked, tabbed, toggle split) +bindsym $mod+s layout stacking +bindsym $mod+w layout tabbed +bindsym $mod+e layout toggle split + +# toggle tiling / floating +bindsym $mod+$shift+space floating toggle + +# change focus between tiling / floating windows +bindsym $mod+space focus mode_toggle + +# focus the parent container +bindsym $mod+a focus parent + +# alt-tab functionality +bindsym $mod+Tab workspace back_and_forth + +######################################### +###### Workspace mgmt #### +######################################### +set $ws1 "1" +set $ws2 "2" +set $ws3 "3" +set $ws4 "4" +set $ws5 "5" +set $ws6 "6" +set $ws7 "7" +set $ws8 "8" +set $ws9 "9" +set $ws10 "10" + +bindsym $mod+1 workspace number $ws1 +bindsym $mod+2 workspace number $ws2 +bindsym $mod+3 workspace number $ws3 +bindsym $mod+4 workspace number $ws4 +bindsym $mod+5 workspace number $ws5 +bindsym $mod+6 workspace number $ws6 +bindsym $mod+7 workspace number $ws7 +bindsym $mod+8 workspace number $ws8 +bindsym $mod+9 workspace number $ws9 +bindsym $mod+0 workspace number $ws10 + +bindsym $mod+$shift+1 move container to workspace number $ws1 +bindsym $mod+$shift+2 move container to workspace number $ws2 +bindsym $mod+$shift+3 move container to workspace number $ws3 +bindsym $mod+$shift+4 move container to workspace number $ws4 +bindsym $mod+$shift+5 move container to workspace number $ws5 +bindsym $mod+$shift+6 move container to workspace number $ws6 +bindsym $mod+$shift+7 move container to workspace number $ws7 +bindsym $mod+$shift+8 move container to workspace number $ws8 +bindsym $mod+$shift+9 move container to workspace number $ws9 +bindsym $mod+$shift+0 move container to workspace number $ws10 + +######################################### +###### Window mgmt #### +######################################### +set $resize_px 10 px +bindsym $mod+$ctrl+$left resize shrink width $resize_px +bindsym $mod+$ctrl+$down resize grow height $resize_px +bindsym $mod+$ctrl+$up resize shrink height $resize_px +bindsym $mod+$ctrl+$right resize grow width $resize_px + +######################################### +###### Session mgmt #### +######################################### +bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'" + +# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and +# suspend and lid close too). +bindsym $mod+Delete exec --no-startup-id loginctl lock-session + +# reload the configuration file +bindsym $mod+$shift+c reload + +# restart i3 inplace (preserves your layout/session, can be used to upgrade i3) +bindsym $mod+$shift+r restart + +######################################### +###### Borders #### +######################################### +default_border pixel 1 +smart_borders on + +######################################### +###### Gaps #### +######################################### +gaps inner 0 +gaps outer 0 + +# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are +# `pixel`, so no title bar shows: the colour says which window has focus. +# border background text indicator child_border +client.focused #de5200 #de5200 #1E2127 #de5200 #de5200 +client.urgent #900000 #900000 #ffffff #900000 #900000 + +######################################### +###### Until their modules carry them ## +######################################### +# Each line below belongs to something other than i3, named on its line. When that module is written +# it contributes the line as its own drop-in in config.d, and the line goes from here in the same +# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs +# (rofi, clipmenu, feh, i3status-rust, gnome-keyring). + +# the peripherals' tray (the operator's application) +exec --no-startup-id polychromatic-tray-applet + +# the operator's scripts: volume, games volume, sessions, screenshot +bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up +bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down +bindsym XF86AudioMute exec --no-startup-id volume-notify mute +bindsym XF86AudioMicMute exec --no-startup-id mic-notify +bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5 +bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5 +bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh +bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh + +######################################### +###### Other modules' drop-ins #### +######################################### +include ~/.config/i3/config.d/*.conf diff --git a/modules/i3/go.mod b/modules/i3/go.mod new file mode 100644 index 0000000..92a8a05 --- /dev/null +++ b/modules/i3/go.mod @@ -0,0 +1,5 @@ +module i3 + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/i3/go.sum b/modules/i3/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/i3/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/i3/internal/desktop/args.go b/modules/i3/internal/desktop/args.go new file mode 100644 index 0000000..d6be351 --- /dev/null +++ b/modules/i3/internal/desktop/args.go @@ -0,0 +1,160 @@ +package desktop + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans. +type Args map[string]any + +// Text is a required string, trimmed. +func (a Args) Text(name string) (string, error) { + v, ok := a[name].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is required, as text", name) + } + return strings.TrimSpace(v), nil +} + +// Opt is an optional string, trimmed, or def. +func (a Args) Opt(name, def string) string { + if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return def +} + +// Has is whether the caller gave the argument at all. +func (a Args) Has(name string) bool { + v, ok := a[name] + return ok && v != nil +} + +// Bool is an optional boolean: its value, and whether it was given. +func (a Args) Bool(name string) (bool, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return false, false, nil + } + b, isBool := v.(bool) + if !isBool { + return false, false, fmt.Errorf("%s is true or false", name) + } + return b, true, nil +} + +// Number is an optional number: its value, and whether it was given. +func (a Args) Number(name string) (float64, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return 0, false, nil + } + f, isNum := v.(float64) + if !isNum || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false, fmt.Errorf("%s is a number", name) + } + return f, true, nil +} + +// Whole is an optional whole number within [lo, hi], or def. +func (a Args) Whole(name string, def, lo, hi int) (int, error) { + f, given, err := a.Number(name) + if err != nil { + return 0, err + } + if !given { + return def, nil + } + if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi) + } + return int(f), nil +} + +// OneOf is an optional string that must be one of choices, or def. +func (a Args) OneOf(name, def string, choices ...string) (string, error) { + v := a.Opt(name, def) + for _, c := range choices { + if v == c { + return v, nil + } + } + return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", ")) +} + +// Strings is an optional list of strings. +func (a Args) Strings(name string) ([]string, error) { + v, ok := a[name] + if !ok || v == nil { + return nil, nil + } + list, isList := v.([]any) + if !isList { + return nil, fmt.Errorf("%s is a list of text", name) + } + out := make([]string, 0, len(list)) + for _, x := range list { + s, isText := x.(string) + if !isText { + return nil, fmt.Errorf("%s is a list of text", name) + } + out = append(out, s) + } + return out, nil +} + +// Home is the operator account's home: the runtime's word for it, else this process's. +func Home() string { + if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" { + return h + } + if h, err := os.UserHomeDir(); err == nil { + return h + } + return "/" +} + +// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path +// that leaves the home through `..` is refused, so a tool that writes never writes outside it. +func InHome(path string) (string, error) { + home := Home() + switch { + case path == "~": + path = home + case strings.HasPrefix(path, "~/"): + path = filepath.Join(home, path[2:]) + case !filepath.IsAbs(path): + path = filepath.Join(home, path) + } + path = filepath.Clean(path) + if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) { + return "", fmt.Errorf("%s is outside the account's home", path) + } + return path, nil +} + +// Schema builds a tool's input schema from property descriptions; required names those that must +// be given. A property is a string unless its description object says otherwise. +func Schema(props map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// Str, Num, Flag, List and Enum describe one property. +func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } +func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} } +func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} } +func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } +func List(desc string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} +} +func Enum(desc string, values ...string) map[string]any { + return map[string]any{"type": "string", "enum": values, "description": desc} +} diff --git a/modules/i3/internal/desktop/copies_test.go b/modules/i3/internal/desktop/copies_test.go new file mode 100644 index 0000000..d6bc1b3 --- /dev/null +++ b/modules/i3/internal/desktop/copies_test.go @@ -0,0 +1,42 @@ +package desktop + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// The desktop modules that carry this package. Each builds alone, so each has its own copy; this +// test, itself one of the copied files, holds them to one text wherever the siblings are present. +var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"} + +func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) { + mine, err := filepath.Glob("*.go") + if err != nil || len(mine) == 0 { + t.Fatal("no files of this package found", err) + } + compared := 0 + for _, module := range carriers { + dir := filepath.Join("..", "..", "..", module, "internal", "desktop") + if _, err := os.Stat(dir); err != nil { + continue + } + theirs, _ := filepath.Glob(filepath.Join(dir, "*.go")) + if len(theirs) != len(mine) { + t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine)) + continue + } + for _, f := range mine { + a, _ := os.ReadFile(f) + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(a, b) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/i3/internal/desktop/run.go b/modules/i3/internal/desktop/run.go new file mode 100644 index 0000000..cb9898c --- /dev/null +++ b/modules/i3/internal/desktop/run.go @@ -0,0 +1,232 @@ +package desktop + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that +// fits in a tool's reply. +const ( + DefaultTimeout = 10 * time.Second + MostOutput = 256 << 10 +) + +// Result is what one command did. +type Result struct { + Command []string `json:"command"` + Code int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` +} + +// OK is whether the command ran and exited 0. +func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut } + +// Err is the command's failure as an error naming it and what it said, or nil. +func (r Result) Err() error { + if r.OK() { + return nil + } + said := strings.TrimSpace(r.Stderr) + if said == "" { + said = strings.TrimSpace(r.Stdout) + } + if r.TimedOut { + return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " ")) + } + return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said) +} + +// Runner runs a command with an environment and answers what it did. Tools take one, so their +// tests replace the machine with a table of answers. +type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result + +// Exec is the machine's Runner: the command in its own process group, ended with everything it +// started at the deadline, each stream cut at MostOutput. +func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + } + res := Result{Command: append([]string{name}, args...)} + cmd := exec.Command(name, args...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + out, errb := &capped{}, &capped{} + cmd.Stdout, cmd.Stderr = out, errb + if err := cmd.Start(); err != nil { + res.Code = 127 + res.Stderr = err.Error() + return res + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-ctx.Done(): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + err = <-done + res.TimedOut = true + } + res.Stdout, res.Stderr = out.String(), errb.String() + res.Truncated = out.cut || errb.cut + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + res.Code = exit.ExitCode() + if res.Code < 0 { + res.Code = 128 + } + default: + res.Code = 1 + if res.Stderr == "" { + res.Stderr = err.Error() + } + } + return res +} + +// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an +// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write. +type capped struct { + buf bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.buf.Len(); room < len(p) { + if room > 0 { + c.buf.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.buf.Write(p) +} + +func (c *capped) String() string { return c.buf.String() } + +// Desk is what a desktop tool needs: how to find the session, and how to run a command. +type Desk struct { + Find func() (*Session, error) + Run Runner + // Base is the environment a command starts from, before the session's words. + Base []string +} + +// Machine is the real Desk, preferring the named processes as the session's. +func Machine(prefer ...string) Desk { + return Desk{ + Find: func() (*Session, error) { return Find(prefer...) }, + Run: Exec, + Base: os.Environ(), + } +} + +// InSession runs a command in the operator's session, or answers NoSession. +func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil +} + +// InSessionWith is InSession with standard input. +func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil +} + +// Plain runs a command with the base environment: for what needs no session. +func (d Desk) Plain(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, d.Base, nil, name, args...) +} + +// AsUser runs a command with the account's own runtime directory and bus, and no display. +func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...) +} + +// Launched is how a program was started in the session. +type Launched struct { + Unit string `json:"unit,omitempty"` + PID int `json:"pid,omitempty"` + How string `json:"how"` +} + +// Launch starts a program in the operator's session that outlives the call and the runtime. +// +// **Not as a child of this process.** The runtime is a system service; everything it starts is in +// its control group, and the service manager ends that group whenever the runtime restarts — which +// is every push that changes it. So the program is handed to the account's own service manager as a +// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the +// operator's user manager does. Without a user manager it is started detached as a last resort, and +// the answer says it will end with the runtime. +func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) { + if len(argv) == 0 { + return Launched{}, errors.New("nothing to launch") + } + env := s.Env(d.Base) + unit := "mesh-" + name + "-" + token() + args := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} { + if v := lookup(env, w); v != "" { + args = append(args, "--setenv="+w+"="+v) + } + } + args = append(args, "--") + args = append(args, argv...) + res := d.Run(ctx, env, nil, "systemd-run", args...) + if res.OK() { + return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil + } + if s.Bus != "" { + return Launched{}, res.Err() + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return Launched{}, err + } + pid := cmd.Process.Pid + go func() { _ = cmd.Wait() }() + return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil +} + +func lookup(env []string, name string) string { + for i := len(env) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(env[i], "="); ok && k == name { + return v + } + } + return "" +} + +func token() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/modules/i3/internal/desktop/session.go b/modules/i3/internal/desktop/session.go new file mode 100644 index 0000000..22d432e --- /dev/null +++ b/modules/i3/internal/desktop/session.go @@ -0,0 +1,445 @@ +// Package desktop is how a desktop module's tools act in the operator's graphical session +// (novox/hq ADR 0208, research 026/05). +// +// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a +// process of node-tools.service, started by the system's service manager as the operator account, +// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in +// was started elsewhere, by the login manager, and the only place its values are written down is +// the environment of the processes it started. So this package finds the session the way a person +// would: it looks at the operator account's own processes, takes the one that is plainly the +// session's (the window manager, or the oldest process carrying a display), confirms with logind +// that its session is a live local one, and checks that the display's socket is really there. +// +// **Only the session's own words are read.** A session's processes also carry whatever its start +// script exported — on the workstations that was a file of secrets — so the environment is filtered +// to a fixed list of names while it is read, and nothing else ever leaves /proc. +// +// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`, +// whenever it exists, because that is where the portal, the notifier and every user service +// listen. A session started on a private bus (a stale session, measured on one workstation) is +// reported as `session_bus` beside it, so the difference is visible rather than guessed at. +// +// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm, +// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change +// every copy together — the modules' tests compare them. +package desktop + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// SessionWords are the only environment words read from a session's process: the ones that say +// where the session is. Everything else in that environment is the operator's, and is never read. +var SessionWords = []string{ + "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", + "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR", + "I3SOCK", "SWAYSOCK", +} + +// Session is the operator's running graphical session, as a tool needs it. +type Session struct { + UID int `json:"uid"` + ID string `json:"session_id,omitempty"` + Type string `json:"type"` + Display string `json:"display,omitempty"` + WaylandDisplay string `json:"wayland_display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + RuntimeDir string `json:"runtime_dir"` + Bus string `json:"bus,omitempty"` + SessionBus string `json:"session_bus,omitempty"` + Desktop string `json:"desktop,omitempty"` + // FoundIn is the process whose environment named the session. + FoundIn Process `json:"found_in"` + // Active is logind's word on the session, when logind answered. + Active *bool `json:"active,omitempty"` + + words map[string]string +} + +// Process is one process the search looked at. +type Process struct { + PID int `json:"pid"` + Command string `json:"command"` + start uint64 +} + +// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool +// that returns it as its error still answers structured data. +type NoSession struct { + Reason string `json:"reason"` + Looked []string `json:"looked"` +} + +func (e *NoSession) Error() string { + b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked}) + return string(b) +} + +// IsNoSession is whether err says there is no session. +func IsNoSession(err error) bool { + var n *NoSession + return errors.As(err, &n) +} + +// Finder holds where the search looks, so a test can point it at a tree of its own. +type Finder struct { + Proc string // the process table: /proc + X11Sockets string // where X servers listen: /tmp/.X11-unix + RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user + UID int // whose session + // Prefer names the processes that are the session's own, best first: the session's holder. + Prefer []string + // Logind answers `loginctl show-session` for one id; nil skips the check. + Logind func(id string) (map[string]string, error) +} + +// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the +// operator account the runtime names (MESH_OPERATOR_ACCOUNT). +func DefaultFinder(prefer ...string) Finder { + uid := os.Getuid() + if uid == 0 { + if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" { + if u, err := user.Lookup(name); err == nil { + if n, err := strconv.Atoi(u.Uid); err == nil { + uid = n + } + } + } + } + return Finder{ + Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user", + UID: uid, Prefer: prefer, Logind: loginctl, + } +} + +// Find is the operator's session on this machine, preferring a process named in prefer. +func Find(prefer ...string) (*Session, error) { + return DefaultFinder(prefer...).Find() +} + +type candidate struct { + proc Process + words map[string]string + rank int + logind map[string]string +} + +// Find looks for the session. +func (f Finder) Find() (*Session, error) { + entries, err := os.ReadDir(f.Proc) + if err != nil { + return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}} + } + looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)} + var found []candidate + stale := 0 + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(f.Proc, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID { + continue + } + words := readWords(filepath.Join(dir, "environ")) + if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" { + continue + } + if !f.reachable(words) { + stale++ + continue + } + found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words}) + } + if len(found) == 0 { + reason := fmt.Sprintf("no process of uid %d carries a display", f.UID) + if stale > 0 { + reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID) + } + return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)} + } + + // logind's word on each session the candidates name, asked once per session. + asked := map[string]map[string]string{} + for i := range found { + id := found[i].words["XDG_SESSION_ID"] + if f.Logind == nil || id == "" { + found[i].rank = 1 + continue + } + props, done := asked[id] + if !done { + props, _ = f.Logind(id) + asked[id] = props + } + found[i].logind = props + switch { + case props == nil: + found[i].rank = 1 + case props["Remote"] == "yes": + found[i].rank = 3 + case props["Active"] == "yes" && props["State"] != "closing": + found[i].rank = 0 + case props["State"] == "closing": + found[i].rank = 3 + default: + found[i].rank = 2 + } + } + if f.Logind != nil { + looked = append(looked, "logind's sessions") + } + preferred := func(c candidate) int { + for i, p := range f.Prefer { + if c.proc.Command == p { + return i + } + } + return len(f.Prefer) + } + sort.SliceStable(found, func(i, j int) bool { + a, b := found[i], found[j] + if a.rank != b.rank { + return a.rank < b.rank + } + if pa, pb := preferred(a), preferred(b); pa != pb { + return pa < pb + } + if a.proc.start != b.proc.start { + return a.proc.start < b.proc.start + } + return a.proc.PID < b.proc.PID + }) + best := found[0] + if best.rank == 3 { + return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked} + } + return f.session(best), nil +} + +func (f Finder) session(c candidate) *Session { + w := c.words + s := &Session{ + UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"], + XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w, + } + s.Desktop = w["XDG_CURRENT_DESKTOP"] + if s.Desktop == "" { + s.Desktop = w["XDG_SESSION_DESKTOP"] + } + switch { + case w["XDG_SESSION_TYPE"] != "": + s.Type = w["XDG_SESSION_TYPE"] + case s.WaylandDisplay != "": + s.Type = "wayland" + default: + s.Type = "x11" + } + if s.RuntimeDir == "" { + s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + if isSocket(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus { + s.SessionBus = own + } + if c.logind != nil { + active := c.logind["Active"] == "yes" + s.Active = &active + } + return s +} + +// reachable is whether the display a process names is still served: the X server's socket, or the +// Wayland compositor's. A process outliving its session still carries the session's words. +func (f Finder) reachable(w map[string]string) bool { + if d := w["WAYLAND_DISPLAY"]; d != "" { + path := d + if !filepath.IsAbs(d) { + dir := w["XDG_RUNTIME_DIR"] + if dir == "" { + dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + path = filepath.Join(dir, d) + } + if isSocket(path) { + return true + } + } + n, ok := DisplayNumber(w["DISPLAY"]) + return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n))) +} + +// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on +// another host — an ssh session's forwarded one — is not the local session and answers false. +func DisplayNumber(display string) (int, bool) { + host, rest, ok := strings.Cut(display, ":") + if !ok || (host != "" && host != "unix") { + return 0, false + } + num, _, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(num) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// Word is one of the session's words as its process had it ("" when it had none). +func (s *Session) Word(name string) string { return s.words[name] } + +// Env is base with the session's words in place of whatever base said for them. +func (s *Session) Env(base []string) []string { + drop := map[string]bool{} + for _, w := range SessionWords { + drop[w] = true + } + out := make([]string, 0, len(base)+8) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if !drop[k] { + out = append(out, kv) + } + } + bus := s.Bus + if bus == "" { + bus = s.SessionBus + } + for _, kv := range [][2]string{ + {"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority}, + {"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus}, + {"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID}, + {"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]}, + {"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]}, + {"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]}, + } { + if kv[1] != "" { + out = append(out, kv[0]+"="+kv[1]) + } + } + return out +} + +// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the +// user manager or the session bus and needs no display — it works with no session at all. +func UserEnv(base []string, uid int) []string { + dir := filepath.Join("/run/user", strconv.Itoa(uid)) + out := make([]string, 0, len(base)+2) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" { + out = append(out, kv) + } + } + return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus")) +} + +// readWords reads a process's environment and keeps only SessionWords. +func readWords(path string) map[string]string { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + keep := map[string]bool{} + for _, w := range SessionWords { + keep[w] = true + } + out := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + k, v, ok := bytes.Cut(kv, []byte{'='}) + if ok && keep[string(k)] { + out[string(k)] = string(v) + } + } + return out +} + +func comm(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "comm")) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// startTime is field 22 of /proc//stat: when the process started, in clock ticks since boot. +// Read after the command's closing parenthesis, because the command may hold spaces. +func startTime(dir string) uint64 { + b, err := os.ReadFile(filepath.Join(dir, "stat")) + if err != nil { + return ^uint64(0) + } + i := bytes.LastIndexByte(b, ')') + if i < 0 { + return ^uint64(0) + } + fields := strings.Fields(string(b[i+1:])) + // fields[0] is the state, field 3 of the line; start time is field 22. + if len(fields) < 20 { + return ^uint64(0) + } + n, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return ^uint64(0) + } + return n +} + +func isSocket(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +// loginctl asks logind about one session, by its property lines. +func loginctl(id string) (map[string]string, error) { + cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class") + var out bytes.Buffer + cmd.Stdout = &out + done := make(chan error, 1) + if err := cmd.Start(); err != nil { + return nil, err + } + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + return nil, err + } + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + return nil, errors.New("loginctl did not answer in 3s") + } + return ParseProperties(out.String()), nil +} + +// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them. +func ParseProperties(text string) map[string]string { + out := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok { + out[k] = v + } + } + return out +} diff --git a/modules/i3/internal/desktop/session_test.go b/modules/i3/internal/desktop/session_test.go new file mode 100644 index 0000000..119c16d --- /dev/null +++ b/modules/i3/internal/desktop/session_test.go @@ -0,0 +1,255 @@ +package desktop + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A machine in a directory: a process table, the X servers' socket directory and a runtime base. +type fakeMachine struct { + t *testing.T + proc, x11, runtime string + uid int +} + +func newMachine(t *testing.T) *fakeMachine { + root, err := os.MkdirTemp("", "desk") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(root) }) + m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()} + for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + return m +} + +func (m *fakeMachine) socket(path string) { + l, err := net.Listen("unix", path) + if err != nil { + m.t.Fatal(err) + } + m.t.Cleanup(func() { l.Close() }) +} + +func (m *fakeMachine) process(pid int, comm string, start int, env ...string) { + dir := filepath.Join(m.proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + m.t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600) + os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + // pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime + // cutime cstime priority nice threads itrealvalue starttime ... + stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0" + os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644) +} + +func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder { + return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind} +} + +func active(id string) (map[string]string, error) { + return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil +} + +func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X1")) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "bus")) + m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1") + m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1", + "XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret") + m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate + + s, err := m.finder(active, "i3").Find() + if err != nil { + t.Fatal(err) + } + if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" { + t.Fatalf("session: %+v", s) + } + if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" { + t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus) + } + if s.Active == nil || !*s.Active { + t.Fatal("logind's word is carried") + } + env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n") + for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} { + if !strings.Contains(env, want) { + t.Errorf("env lacks %s:\n%s", want, env) + } + } + if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") { + t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env) + } + b, _ := json.Marshal(s) + if strings.Contains(string(b), "secret") { + t.Fatal("the answer carries a word outside the session's") + } +} + +func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3") + m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3") + s, err := m.finder(nil).Find() + if err != nil || s.FoundIn.PID != 400 { + t.Fatalf("%+v %v", s, err) + } + if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" { + t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s) + } +} + +func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) { + m := newMachine(t) + m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket + _, err := m.finder(active, "i3").Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") { + t.Fatalf("%v", err) + } + var answer map[string]any + if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" { + t.Fatalf("the refusal is structured: %s", err) + } +} + +func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) { + m := newMachine(t) + m.process(600, "sshd", 1, "SSH_CONNECTION=x") + _, err := m.finder(active).Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") { + t.Fatalf("%v", err) + } +} + +func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.socket(filepath.Join(m.x11, "X1")) + m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a") + m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b") + logind := func(id string) (map[string]string, error) { + if id == "a" { + return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil + } + return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil + } + s, err := m.finder(logind, "i3").Find() + if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" { + t.Fatalf("the active session: %+v %v", s, err) + } + remote := func(string) (map[string]string, error) { + return map[string]string{"Active": "yes", "Remote": "yes"}, nil + } + if _, err := m.finder(remote).Find(); !IsNoSession(err) { + t.Fatalf("a remote session is not the operator's desktop: %v", err) + } +} + +func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) { + m := newMachine(t) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "wayland-1")) + m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock") + s, err := m.finder(nil, "sway").Find() + if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" { + t.Fatalf("%+v %v", s, err) + } + if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") { + t.Fatal("the compositor's socket word passes") + } +} + +func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) { + for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} { + if _, ok := DisplayNumber(d); ok != want { + t.Errorf("%q: %v", d, ok) + } + } +} + +func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) { + r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat") + if !r.OK() || r.Stdout != "hello" { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3") + if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here") + if r.OK() || r.Code != 127 { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero") + if !r.Truncated || len(r.Stdout) != MostOutput { + t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated) + } +} + +func TestArgumentsAreReadStrictly(t *testing.T) { + a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if v, err := a.Text("name"); err != nil || v != "x" { + t.Fatal(v, err) + } + if _, err := a.Text("missing"); err == nil { + t.Fatal("a missing required text") + } + if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 { + t.Fatal(n, err) + } + if _, err := a.Whole("f", 0, 0, 5); err == nil { + t.Fatal("1.5 is not whole") + } + if _, err := a.Whole("n", 0, 4, 5); err == nil { + t.Fatal("out of range") + } + if b, given, err := a.Bool("b"); !b || !given || err != nil { + t.Fatal("bool") + } + if _, _, err := a.Bool("name"); err == nil { + t.Fatal("text is not a bool") + } + if l, err := a.Strings("l"); err != nil || len(l) != 2 { + t.Fatal(l, err) + } + if _, err := a.OneOf("name", "", "y", "z"); err == nil { + t.Fatal("not one of") + } +} + +func TestAPathIsKeptInsideTheHome(t *testing.T) { + t.Setenv("MESH_OPERATOR_HOME", "/home/op") + for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} { + if got, err := InHome(in); err != nil || got != want { + t.Errorf("%s: %s %v", in, got, err) + } + } + for _, out := range []string{"/etc/passwd", "~/../other", "../x"} { + if _, err := InHome(out); err == nil { + t.Errorf("%s was accepted", out) + } + } +} + +func TestPropertiesAreParsed(t *testing.T) { + p := ParseProperties("Active=yes\nState=active\nDisplay=\n") + if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" { + t.Fatal(p) + } +} diff --git a/modules/i3/module.json b/modules/i3/module.json new file mode 100644 index 0000000..b44fa3f --- /dev/null +++ b/modules/i3/module.json @@ -0,0 +1,102 @@ +{ + "module": "i3", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-display-session", + "scope": "node", + "serves": [ + "reload", + "workspaces", + "windows" + ] + } + ], + "tools": [ + "i3_focus", + "i3_move", + "i3_layout_save", + "i3_layout_restore", + "i3_exec", + "i3_kill", + "i3_bindings", + "i3_config_check", + "i3_marks", + "i3_scratchpad" + ], + "environment": { + "variables": { + "XDG_CURRENT_DESKTOP": "i3", + "XDG_SESSION_DESKTOP": "i3" + } + }, + "shell": [ + { + "for": "xinitrc", + "slot": "last", + "code": "# The session: i3, with 25 MiB of debug log kept in memory for i3-dump-log.\nexec i3 --shmlog-size=26214400\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3-wm" + }, + { + "id": "package-dex", + "type": "package", + "package": "dex" + }, + { + "id": "config-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "drop-ins", + "type": "directory", + "path": "${machine:account-home}/.config/i3/config.d", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "config", + "type": "file", + "path": "${machine:account-home}/.config/i3/config", + "owner": "${machine:account}", + "mode": "0644", + "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named\n# -.conf and read in name order by the include at the end, where every variable set here\n# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line as its own drop-in in config.d, and the line goes from here in the same\n# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs\n# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).\n\n# the peripherals' tray (the operator's application)\nexec --no-startup-id polychromatic-tray-applet\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' drop-ins ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n" + }, + { + "id": "session", + "type": "file", + "path": "/etc/lemurs/wms/i3", + "mode": "0755", + "content": "#!/bin/sh\n# The i3 session (module i3, novox/hq ADR 0208), offered by the login manager. It runs the session's\n# start, ~/.xinitrc, which the display server's module writes and which ends by starting i3.\n# Replaced at every push.\n#\n# The login manager hands the session a stdout and stderr whose reading end nobody holds, so a\n# program that writes to them dies of EPIPE (an Electron tray app shows it as an error dialog).\n# The session's output goes to the journal instead, under `journalctl -t x-session`.\nexec systemd-cat -t x-session /bin/sh \"$HOME/.xinitrc\"\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3-tools", + "binary": "i3-tools", + "loads": [ + "i3-tools" + ] + } + ] + } +} diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md new file mode 100644 index 0000000..6759d3c --- /dev/null +++ b/modules/i3status-rust/README.md @@ -0,0 +1,86 @@ +# i3status-rust + +The bars as a module (novox/hq ADR 0208, research 026/05). + +- Installs `i3status-rust`, and `pacman-contrib` for `checkupdates`, which the update block uses. + Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own + machine: its bars are i3bar's. +- Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set + (`icons/custom-icons.toml`). +- Places the two `bar { }` blocks as its own i3 drop-in, `~/.config/i3/config.d/60-i3status-rust.conf`: + - the bottom bar shows the machine; + - the top bar shows the focused window and the tray, on the primary output. +- Places two programs in `~/.local/bin`: + - `i3status-updates`, the pending-updates block; + - `i3bar-watchdog`, which brings back a bar that died. +- Starts the watchdog once per session, from the session's start (the `xinitrc` slot `normal`). It ends + with the session's own process. + +## Tools + +| tool | does | +|---|---| +| `i3status_rust_reload` | the running bars re-read their files (i3status-rs restarts in place) | +| `i3status_rust_blocks` | each bar's blocks in order, kind and settings, and what each shows now (one run of the bar) | +| `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors | +| `i3status_rust_themes` | the themes on the machine and the one each bar uses | + +## The battery, and what else was left out + +A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208 +§1), so the bottom bar here has none: + +- **the battery,** which only the laptop has; +- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop; +- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's. + +i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only, +so a hardware module has no way into this file yet. Two ways forward, for the operator to choose: + +1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's + hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4. +2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for + example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware. + That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar. + +Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned. + +## What it improves on what was found + +- **The weather needs no key.** The found block used a weather service with an API key written in + plain text in the file, and a fixed city. It now uses the Norwegian Meteorological Institute, which + needs no key, located automatically. +- **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It + counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the + launcher's menu, instead of a theme from a cloned theme repository. +- **The faces** are JetBrains Mono Nerd Font, not Hack. +- **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the + other. It runs on both, once per session, and ends with the session. +- The docker block's click, which ran a program installed on neither machine, is gone. + +## What it leaves as found + +- `~/.config/i3status-rust/scripts/` (a mouse battery script for one device). +- `~/scripts/pkg-updates` and `~/scripts/i3-bar-watchdog`, replaced here. +- The user unit `~/.config/systemd/user/i3-bar-watchdog.service` and its enablement link. + +## Migration (ADR 0182) + +1. **Revoke the weather API key** that was in the found `bottom-bar.toml`. It sat in plain text on + both workstations. The first push keeps the found file once and then writes the module's, which + has no key. +2. Before the first push, on the laptop: `systemctl --user disable --now i3-bar-watchdog.service`. + Otherwise two watchdogs run. Then delete the unit file, `~/scripts/i3-bar-watchdog` and + `~/scripts/pkg-updates`. +3. Until the `i3` module carries the main configuration, the found `~/.config/i3/config` still has its + own two `bar { }` blocks, and i3 shows four bars. The `i3` module's configuration has none. + +## Blockers + +- `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, + `mctl` reads them as unknown. +- The battery block waits for one of the two ways above. +- The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it + runs per session and ends with the session, as a session-start line already does. +- `pacman-contrib` is declared here. A future `pacman` module that wants `checkupdates` or `paccache` + takes it over, since a package is declared once per node. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/args.go b/modules/i3status-rust/cmd/i3status-rust-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-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/i3status-rust/cmd/i3status-rust-tools/bar.go b/modules/i3status-rust/cmd/i3status-rust-tools/bar.go new file mode 100644 index 0000000..7914d41 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/bar.go @@ -0,0 +1,306 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +var bars = []string{"top", "bottom"} + +// A bar runs this long when a tool reads its values: long enough for every block's first update. +const runFor = 4 * time.Second + +func configDir() string { return filepath.Join(operatorHome(), ".config", "i3status-rust") } + +func barFile(bar string) string { return filepath.Join(configDir(), bar+"-bar.toml") } + +func barsOf(bar string) ([]string, error) { + if bar == "" { + return bars, nil + } + for _, b := range bars { + if b == bar { + return []string{bar}, nil + } + } + return nil, fmt.Errorf("bar %q is top or bottom", bar) +} + +// ReloadResult is what i3status_rust_reload answers. +type ReloadResult struct { + Reached []int `json:"reached"` + Note string `json:"note"` +} + +// Reload restarts every running i3status-rs in place, which re-reads its file (SIGUSR2). +func Reload() ReloadResult { + reached := signalAll("i3status-rs", syscall.SIGUSR2) + if reached == nil { + return ReloadResult{Reached: []int{}, Note: "no bar is running: i3 starts them with the session"} + } + return ReloadResult{Reached: reached, Note: "each bar re-read its configuration"} +} + +// Config is a bar's file cut into the part before its blocks and each block's own text. +type Config struct { + Header string + Blocks []string +} + +var blockStart = regexp.MustCompile(`(?m)^\[\[block\]\]\s*$`) + +func splitConfig(raw string) Config { + idx := blockStart.FindAllStringIndex(raw, -1) + if len(idx) == 0 { + return Config{Header: raw} + } + c := Config{Header: raw[:idx[0][0]]} + for i, at := range idx { + end := len(raw) + if i+1 < len(idx) { + end = idx[i+1][0] + } + c.Blocks = append(c.Blocks, raw[at[0]:end]) + } + return c +} + +// Block is one block of a bar. +type Block struct { + Index int `json:"index"` + Kind string `json:"block"` + Settings map[string]string `json:"settings"` + Text *string `json:"text,omitempty"` + State string `json:"state,omitempty"` +} + +var keyValue = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`) + +// describe reads a block's own top-level settings (not its sub-tables, such as clicks), as written. +func describe(index int, raw string) Block { + b := Block{Index: index, Settings: map[string]string{}} + for _, line := range strings.Split(raw, "\n")[1:] { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") { + break + } + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if m := keyValue.FindStringSubmatch(line); m != nil { + v := strings.Trim(m[2], `"`) + if m[1] == "block" { + b.Kind = v + } else { + b.Settings[m[1]] = v + } + } + } + return b +} + +// BarBlocks is one bar's blocks. +type BarBlocks struct { + Bar string `json:"bar"` + File string `json:"file"` + Blocks []Block `json:"blocks"` + Error string `json:"error,omitempty"` +} + +// Blocks describes each bar's blocks, and with values what each shows now. +func Blocks(bar string, values bool) ([]BarBlocks, error) { + which, err := barsOf(bar) + if err != nil { + return nil, err + } + var out []BarBlocks + for _, b := range which { + file := barFile(b) + raw, err := os.ReadFile(file) + if err != nil { + out = append(out, BarBlocks{Bar: b, File: file, Blocks: []Block{}, Error: err.Error()}) + continue + } + c := splitConfig(string(raw)) + bb := BarBlocks{Bar: b, File: file, Blocks: []Block{}} + for i, text := range c.Blocks { + bb.Blocks = append(bb.Blocks, describe(i, text)) + } + if values { + shown, err := runBar(file) + if err != nil { + bb.Error = err.Error() + } + for i := range bb.Blocks { + if w, ok := shown[i]; ok { + text := w.text + bb.Blocks[i].Text, bb.Blocks[i].State = &text, w.state + } + } + } + out = append(out, bb) + } + return out, nil +} + +type widgetText struct{ text, state string } + +// runBar runs i3status-rs on a file for a moment and answers each block's latest text, by index. +// i3status-rs names each block's widgets by an instance ":…", which is how they are told apart. +func runBar(file string) (map[int]widgetText, error) { + s := findEnvironment() // the session when there is one; blocks that need none run without + r, err := s.run(runFor, "", "i3status-rs", file) + if err != nil && !errors.Is(err, ErrTimedOut) { + return nil, err + } + shown := parseStatus(r.Stdout) + if len(shown) == 0 { + msg := strings.TrimSpace(r.Stderr) + if msg == "" { + msg = "the bar showed nothing within " + runFor.String() + } + return shown, errors.New(msg) + } + return shown, nil +} + +// parseStatus reads i3bar's protocol — a header, "[", then one JSON array per update — and answers +// the last complete update's text per block index. +func parseStatus(stream string) map[int]widgetText { + var last []map[string]any + for _, line := range strings.Split(stream, "\n") { + line = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), ","), ",") + if !strings.HasPrefix(line, "[{") && line != "[]" { + continue + } + var update []map[string]any + if json.Unmarshal([]byte(line), &update) == nil { + last = update + } + } + out := map[int]widgetText{} + for _, w := range last { + instance, _ := w["instance"].(string) + head, _, ok := strings.Cut(instance, ":") + if !ok { + continue // a separator + } + i, err := strconv.Atoi(head) + if err != nil { + continue + } + text, _ := w["full_text"].(string) + cur := out[i] + cur.text = strings.TrimSpace(strings.TrimSpace(cur.text) + " " + strings.TrimSpace(text)) + if colour, _ := w["color"].(string); strings.EqualFold(colour, "#FF0000FF") { + cur.state = "critical" + } + out[i] = cur + } + return out +} + +// RunResult is what i3status_rust_block_run answers. +type RunResult struct { + Bar string `json:"bar"` + Block Block `json:"block"` + Error string `json:"error,omitempty"` +} + +// BlockRun runs one block of a bar alone, with the bar's theme and icons. +func BlockRun(bar string, index int) (RunResult, error) { + if _, err := barsOf(bar); err != nil || bar == "" { + return RunResult{}, fmt.Errorf("bar %q is top or bottom", bar) + } + raw, err := os.ReadFile(barFile(bar)) + if err != nil { + return RunResult{}, err + } + c := splitConfig(string(raw)) + if index >= len(c.Blocks) { + return RunResult{}, fmt.Errorf("the %s bar has %d blocks, numbered from 0", bar, len(c.Blocks)) + } + tmp, err := os.CreateTemp("", "i3status-rust-block-*.toml") + if err != nil { + return RunResult{}, err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.WriteString(c.Header + c.Blocks[index]); err != nil { + tmp.Close() + return RunResult{}, err + } + tmp.Close() + out := RunResult{Bar: bar, Block: describe(index, c.Blocks[index])} + shown, err := runBar(tmp.Name()) + if err != nil { + out.Error = err.Error() + } + if w, ok := shown[0]; ok { + text := w.text + out.Block.Text, out.Block.State = &text, w.state + } + return out, nil +} + +// Theme is one bar theme. +type Theme struct { + Name string `json:"name"` + File string `json:"file"` + Source string `json:"source"` +} + +// ThemesResult is what i3status_rust_themes answers. +type ThemesResult struct { + InUse map[string]string `json:"in_use"` + Themes []Theme `json:"themes"` +} + +type themeDir struct{ path, source string } + +func themeDirs() []themeDir { + return []themeDir{ + {filepath.Join(configDir(), "themes"), "the account's"}, + {filepath.Join(operatorHome(), ".local", "share", "i3status-rust", "themes"), "the account's"}, + {"/usr/share/i3status-rust/themes", "the distribution's"}, + } +} + +var themeName = regexp.MustCompile(`(?ms)^\[theme\]\s*$.*?^theme\s*=\s*"([^"]+)"`) + +// Themes lists the themes, the first directory winning a name, and the one each bar names. +func Themes(dirs []themeDir) ThemesResult { + out := ThemesResult{InUse: map[string]string{}, Themes: []Theme{}} + for _, b := range bars { + if raw, err := os.ReadFile(barFile(b)); err == nil { + if m := themeName.FindStringSubmatch(string(raw)); m != nil { + out.InUse[b] = 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(), ".toml") + if !ok || 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 +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go new file mode 100644 index 0000000..fb1da5d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/bar_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func source(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "files", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestTheModulesBarsSplitIntoTheirBlocksInOrder(t *testing.T) { + c := splitConfig(source(t, "bottom-bar.toml")) + var kinds []string + for i, b := range c.Blocks { + kinds = append(kinds, describe(i, b).Kind) + } + want := []string{"tea_timer", "custom", "cpu", "disk_space", "disk_space", "memory", "docker", "sound", "weather", "notify", "time"} + if !reflect.DeepEqual(kinds, want) { + t.Fatalf("%v", kinds) + } + if !strings.Contains(c.Header, `theme = "plain"`) || strings.Contains(c.Header, "[[block]]") { + t.Fatalf("header: %s", c.Header) + } + custom := describe(1, c.Blocks[1]) + if custom.Settings["command"] != "~/.local/bin/i3status-updates 10" || custom.Settings["interval"] != "1800" { + t.Fatalf("a block's own settings, not its click's: %+v", custom) + } + if _, leaked := custom.Settings["cmd"]; leaked { + t.Fatal("the click's cmd was read as the block's") + } + top := splitConfig(source(t, "top-bar.toml")) + if len(top.Blocks) != 2 || describe(0, top.Blocks[0]).Kind != "focused_window" { + t.Fatalf("top: %+v", top.Blocks) + } +} + +// Two updates of i3bar's protocol, as i3status-rs wrote them for the top bar on 2026-10-04. +const stream = `{"version": 1, "click_events": true} +[ +[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" up 1d ","color":"#F1F1F1FF","name":"0","instance":"1:"}], +[{"full_text":" | ","color":"#FFFFFFFF","separator":false},{"full_text":" ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":"failed to connect to wayland ","color":"#FF0000FF","name":"0","instance":"0:"},{"full_text":" | "},{"full_text":" up 2d ","color":"#F1F1F1FF","name":"0","instance":"1:"}], +` + +func TestTheLatestUpdateIsReadPerBlockIndex(t *testing.T) { + got := parseStatus(stream) + if got[1].text != "up 2d" || got[1].state != "" || got[0].text != "failed to connect to wayland" || got[0].state != "critical" || len(got) != 2 { + t.Fatalf("%+v", got) + } + if len(parseStatus("garbage\n[\n")) != 0 { + t.Fatal("no update is no blocks") + } +} + +// installBars puts the module's bars where i3status-rs would read them, and a fake i3status-rs. +func installBars(t *testing.T) string { + t.Helper() + root := fakeMachine(t) + dir := filepath.Join(root, "home", ".config", "i3status-rust") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, f := range []string{"top-bar.toml", "bottom-bar.toml"} { + if err := os.WriteFile(filepath.Join(dir, f), []byte(source(t, f)), 0o644); err != nil { + t.Fatal(err) + } + } + bin := fakeBinaries(t, map[string]string{"i3status-rs": `cp "$1" "$LOG.config" +printf '%s\n' '{"version": 1}' '[' '[{"full_text":"first","instance":"0:"},{"full_text":"second","instance":"1:"}],'`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + return bin +} + +func TestBlocksPairEachBlockWithWhatItShows(t *testing.T) { + installBars(t) + got, err := Blocks("top", true) + if err != nil || len(got) != 1 { + t.Fatalf("%+v, %v", got, err) + } + b := got[0].Blocks + if len(b) != 2 || b[0].Kind != "focused_window" || b[0].Text == nil || *b[0].Text != "first" || *b[1].Text != "second" { + t.Fatalf("%+v", got) + } + if all, _ := Blocks("", false); len(all) != 2 || all[1].Blocks[0].Text != nil { + t.Fatalf("both bars, without values: %+v", all) + } + if _, err := Blocks("side", false); err == nil { + t.Fatal("an unknown bar was accepted") + } +} + +func TestOneBlockRunsAloneWithTheBarsThemeAndIcons(t *testing.T) { + bin := installBars(t) + got, err := BlockRun("bottom", 8) + if err != nil || got.Block.Kind != "weather" || got.Block.Text == nil || *got.Block.Text != "first" { + t.Fatalf("%+v, %v", got, err) + } + ran, _ := os.ReadFile(filepath.Join(bin, "log.config")) + if strings.Count(string(ran), "[[block]]") != 1 || !strings.Contains(string(ran), `name = "metno"`) || + !strings.Contains(string(ran), `icons = "custom-icons"`) { + t.Fatalf("the config it ran:\n%s", ran) + } + if _, err := BlockRun("bottom", 11); err == nil { + t.Fatal("a block past the end was accepted") + } + if _, err := BlockRun("", 0); err == nil { + t.Fatal("no bar was accepted") + } +} + +func TestThemesListEachOnceWithTheOnesInUse(t *testing.T) { + installBars(t) + root := t.TempDir() + for _, f := range []string{"mine/plain.toml", "system/plain.toml", "system/nord-dark.toml", "system/README"} { + if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil { + t.Fatal(err) + } + } + got := Themes([]themeDir{{filepath.Join(root, "mine"), "mine"}, {filepath.Join(root, "system"), "system"}}) + if got.InUse["top"] != "plain" || got.InUse["bottom"] != "plain" || len(got.Themes) != 2 || got.Themes[1].Source != "mine" { + t.Fatalf("%+v", got) + } +} + +func TestReloadWithNoBarRunningSaysSo(t *testing.T) { + fakeMachine(t) + if r := Reload(); len(r.Reached) != 0 || !strings.Contains(r.Note, "no bar") { + t.Fatalf("%+v", r) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/main.go b/modules/i3status-rust/cmd/i3status-rust-tools/main.go new file mode 100644 index 0000000..90918c1 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/main.go @@ -0,0 +1,81 @@ +// i3status-rust's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the bars' tools, served by +// the node's runtime as the operator account. node-bar has no verbs yet (ADR 0208 §2), so every tool +// here is the module's own. +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) + } +} + +var barInput = map[string]any{"type": "string", "enum": bars, "description": "which bar (default: both)"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "i3status_rust_reload", + Description: "Make the running bars re-read their configuration (i3status-rs restarts in place). " + + "Answers the processes reached.", + Run: func(map[string]any) (any, error) { return Reload(), nil }, + }, + { + Name: "i3status_rust_blocks", + Description: "What each bar shows: every block in order, its kind and settings, and with values " + + "(the default) what it shows right now, from one run of the bar's configuration.", + Input: map[string]any{ + "bar": barInput, + "values": map[string]any{"type": "boolean", "description": "run the bar once to read each block's current text (default true; a few seconds)"}, + }, + Run: func(args map[string]any) (any, error) { + bar, err := text(args, "bar", false) + if err != nil { + return nil, err + } + values, err := flag(args, "values", true) + if err != nil { + return nil, err + } + return Blocks(bar, values) + }, + }, + { + Name: "i3status_rust_block_run", + Description: "Run one block of a bar once, alone, and answer what it shows — to see a block that " + + "errors, or what a click would act on.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "bar": map[string]any{"type": "string", "enum": bars}, + "index": map[string]any{"type": "integer", "description": "the block's position in the bar, from 0, as i3status_rust_blocks answers it"}, + }, + "required": []string{"bar", "index"}, + }, + Run: func(args map[string]any) (any, error) { + bar, err := text(args, "bar", true) + if err != nil { + return nil, err + } + index, err := whole(args, "index", 0, 0, 200) + if err != nil { + return nil, err + } + return BlockRun(bar, index) + }, + }, + { + Name: "i3status_rust_themes", + Description: "The bar themes on this machine (the distribution's and the account's own) and the " + + "one each bar uses.", + Run: func(map[string]any) (any, error) { return Themes(themeDirs()), nil }, + }, + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-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/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go new file mode 100644 index 0000000..0b4dde1 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// i3status-rust's shape (novox/hq ADR 0208): it claims node-bar, requires the X display on its own +// machine (its bars are i3bar's), owns its two bars and their icons, places the bars as an i3 drop-in, +// and runs the bar watchdog once per session. No block follows one machine's hardware or one person's +// devices. + +func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "i3status-rust" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) { + 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{"i3status-rust", "pacman-contrib"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { + m := readManifest(t) + for id, src := range map[string]string{ + "top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml", + "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf", + } { + m.sameAsSource(t, id, src) + } +} + +func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testing.T) { + m := readManifest(t) + bottom := m.resource(t, "bottom-bar")["content"].(string) + for _, never := range []string{`block = "battery"`, `block = "amd_gpu"`, `block = "nvidia_gpu"`, "mac = ", "api_key", "city_id", "openweathermap", "~/scripts/"} { + if strings.Contains(bottom, never) { + t.Errorf("the bottom bar has %s", never) + } + } +} + +func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) { + m := readManifest(t) + bars := m.resource(t, "i3-bars") + if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" { + t.Fatalf("%v", bars["path"]) + } + c := bars["content"].(string) + if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") || + !strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") { + t.Fatalf("%s", c) + } + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || + !strings.Contains(m.Shell[0].Code, `"$HOME/.local/bin/i3bar-watchdog" "$$" &`) { + t.Fatalf("%+v", m.Shell) + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/session.go b/modules/i3status-rust/cmd/i3status-rust-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-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/i3status-rust/cmd/i3status-rust-tools/session_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-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/i3status-rust/files/bin/i3bar-watchdog b/modules/i3status-rust/files/bin/i3bar-watchdog new file mode 100755 index 0000000..63b2b02 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3bar-watchdog @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died. +# +# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not +# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the +# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the +# cause, which is i3bar's. +# +# Started once per session from the session's start, with the session's own pid; it ends when that +# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04. +set -uo pipefail + +session_pid="${1:-}" +interval="${I3_BAR_WATCHDOG_INTERVAL:-5}" +# Two misses in a row before acting: an i3 restart tears every bar down and starts them again. +confirm="${I3_BAR_WATCHDOG_CONFIRM:-2}" +# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop. +cooldown="${I3_BAR_WATCHDOG_COOLDOWN:-30}" + +log() { printf 'i3bar-watchdog: %s\n' "$*" >&2; } + +for tool in i3-msg i3bar pgrep; do + command -v "$tool" >/dev/null 2>&1 || { + log "$tool is missing; not watching" + exit 1 + } +done + +declare -A missed=() fixed=() + +bar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '"[^"]+"' | tr -d '"'; } +outputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2" "$3}' | tr '\n' ' '; } +session_alive() { [ -z "$session_pid" ] || kill -0 "$session_pid" 2>/dev/null; } + +while session_alive; do + sleep "$interval" + socket="$(i3 --get-socketpath 2>/dev/null)" + [ -n "$socket" ] && [ -S "$socket" ] || continue + ids="$(bar_ids)" + [ -n "$ids" ] || continue + while read -r id; do + [ -n "$id" ] || continue + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + continue + fi + missed[$id]=$((${missed[$id]:-0} + 1)) + [ "${missed[$id]}" -ge "$confirm" ] || continue + now="$(date +%s)" + if [ $((now - ${fixed[$id]:-0})) -lt "$cooldown" ]; then + continue + fi + log "$id is gone; respawning it. Outputs now: $(outputs)" + nohup i3bar --bar_id="$id" --socket="$socket" >/dev/null 2>&1 & + disown 2>/dev/null || true + fixed[$id]="$now" + sleep 2 + if pgrep -u "$EUID" -f -- "i3bar --bar_id=$id" >/dev/null 2>&1; then + missed[$id]=0 + else + log "$id exited within 2s of respawning; check its status_command" + fi + done <<<"$ids" +done diff --git a/modules/i3status-rust/files/bin/i3status-updates b/modules/i3status-rust/files/bin/i3status-updates new file mode 100755 index 0000000..1a70cf9 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3status-updates @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for +# the bottom bar's custom block (json = true). Emits one JSON line. +# +# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw +# red error on every transient network failure, which on a roaming laptop is often. This keeps the +# last good result through a failure and shows an error only after `threshold` failures in a row +# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no +# root); AUR packages through paru when it is installed, and none counted when it is not. +set -uo pipefail + +threshold="${1:-10}" +icon="update" +state_dir="${XDG_RUNTIME_DIR:-/tmp}/i3status-updates" +mkdir -p "$state_dir" +last="$state_dir/last_good.json" +failures="$state_dir/failures" + +json() { printf '{"icon":"%s","state":"%s","text":"%s","short_text":"%s"}' "$icon" "$1" "$2" "$3"; } +count() { [ -n "$1" ] && printf '%s\n' "$1" | grep -c . || echo 0; } + +official="$(checkupdates 2>/dev/null)" +official_rc=$? +aur="" aur_rc=0 +if command -v paru >/dev/null 2>&1; then + aur="$(paru -Qua 2>/dev/null)" + aur_rc=$? +fi + +# checkupdates: 0 updates listed, 2 none pending; anything else is a failure. +if { [ "$official_rc" -eq 0 ] || [ "$official_rc" -eq 2 ]; } && [ "$aur_rc" -eq 0 ]; then + o="$(count "$official")" + a="$(count "$aur")" + total=$((o + a)) + if [ "$total" -eq 0 ]; then + payload="$(json Good "up to date" "")" + else + # A kernel update wants a reboot: the one worth not putting off. + state=Info + printf '%s\n' "$official" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning + label="$o + $a = $total updates" + [ "$a" -eq 0 ] && label="$total updates" + [ "$total" -eq 1 ] && label="1 update" + payload="$(json "$state" "$label" "$total")" + fi + printf '%s' "$payload" >"$last" + printf '0' >"$failures" + printf '%s\n' "$payload" + exit 0 +fi + +n=$(($(cat "$failures" 2>/dev/null || echo 0) + 1)) +printf '%s' "$n" >"$failures" +if [ "$n" -ge "$threshold" ]; then + json Critical "update check failing (${n}x)" "!" + echo +elif [ -s "$last" ]; then + cat "$last" + echo +else + json Idle "" "" + echo +fi diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml new file mode 100644 index 0000000..8098093 --- /dev/null +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -0,0 +1,100 @@ +# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every +# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one +# person's devices left out: the battery and the GPU belong to a machine model's hardware module, +# and a headset's address is not the bar's to know. Every block here works the same on any machine. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "tea_timer" +format = "$icon{ $minutes:$seconds|}" +done_cmd = "notify-send 'Timer finished'" + +# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good +# count through a network gap and shows an error only after ten failed checks in a row. Click: the +# list, in the launcher's menu. +[[block]] +block = "custom" +command = "~/.local/bin/i3status-updates 10" +json = true +interval = 1800 +[[block.click]] +button = "left" +cmd = "checkupdates | dmenu -l 20 -p Updates" + +[[block]] +block = "cpu" + +[[block]] +block = "disk_space" +path = "/" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon / $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "disk_space" +path = "/home" +info_type = "used" +alert_unit = "GB" +interval = 20 +warning = 850 +alert = 920 +format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" + +[[block]] +block = "memory" +format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))" +format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" + +[[block]] +block = "docker" +interval = 2 +format = "$icon $running/$total" + +[[block]] +block = "sound" +[[block.click]] +button = "left" +cmd = "pavucontrol" + +# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. +[[block]] +block = "weather" +format = "$icon $weather_verbose ($location) $temp" +autolocate = true +autolocate_interval = 600 +[block.service] +name = "metno" + +[[block]] +block = "notify" +driver = "dunst" +format = " $icon {($notification_count.eng(w:1))|}" + +[[block]] +block = "time" +interval = 1 +format = "$timestamp.datetime(f:'%a %d/%m %H:%M:%S')" diff --git a/modules/i3status-rust/files/i3/60-i3status-rust.conf b/modules/i3status-rust/files/i3/60-i3status-rust.conf new file mode 100644 index 0000000..20acbb1 --- /dev/null +++ b/modules/i3status-rust/files/i3/60-i3status-rust.conf @@ -0,0 +1,34 @@ +# The bars (module i3status-rust, 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`. The bottom +# bar shows the machine; the top bar the focused window and the tray, on the primary output. The +# face is the monospace one every desktop module names. +bar { + font pango:JetBrainsMono Nerd Font 11 + position bottom + status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml + tray_output none + colors { + separator #ffffff + background #000000 + statusline #ffffff + # The text on an accent-coloured button is dark: light text on the accent was barely + # legible. + focused_workspace #de5200 #de5200 #000000 + active_workspace #de5200 #de5200 #000000 + inactive_workspace #000000 #000000 #ffffff + urgent_workspace #2f343a #900000 #ffffff + } +} + +bar { + font pango:JetBrainsMono Nerd Font 11 + position top + status_command i3status-rs ~/.config/i3status-rust/top-bar.toml + workspace_buttons no + tray_output primary + colors { + separator #ffffff + background #000000 + statusline #ffffff + } +} diff --git a/modules/i3status-rust/files/icons/custom-icons.toml b/modules/i3status-rust/files/icons/custom-icons.toml new file mode 100644 index 0000000..9636a82 --- /dev/null +++ b/modules/i3status-rust/files/icons/custom-icons.toml @@ -0,0 +1,135 @@ +# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +# Material from NerdFont +# Codepoints from the Nerd Fonts cheat sheet +backlight = [ + "\ue38d", # nf-weather-moon_new + "\ue3d4", # nf-weather-moon_alt_waxing_gibbous_6 + "\ue3d3", # nf-weather-moon_alt_waxing_gibbous_5 + "\ue3d2", # nf-weather-moon_alt_waxing_gibbous_4 + "\ue3d1", # nf-weather-moon_alt_waxing_gibbous_3 + "\ue3d0", # nf-weather-moon_alt_waxing_gibbous_2 + "\ue3cf", # nf-weather-moon_alt_waxing_gibbous_1 + "\ue3ce", # nf-weather-moon_alt_first_quarter + "\ue3cd", # nf-weather-moon_alt_waxing_crescent_6 + "\ue3cc", # nf-weather-moon_alt_waxing_crescent_5 + "\ue3cb", # nf-weather-moon_alt_waxing_crescent_4 + "\ue3ca", # nf-weather-moon_alt_waxing_crescent_3 + "\ue3c9", # nf-weather-moon_alt_waxing_crescent_2 + "\ue3c8", # nf-weather-moon_alt_waxing_crescent_1 + "\ue39b", # nf-weather-moon_full +] +bat_charging = "\U000f0084" # nf-md-battery_charging +bat_not_available = "\U000f0091" # nf-md-battery_unknown +bat = [ + "\U000f007a", # nf-md-battery_10 + "\U000f007b", # nf-md-battery_20 + "\U000f007c", # nf-md-battery_30 + "\U000f007d", # nf-md-battery_40 + "\U000f007e", # nf-md-battery_50 + "\U000f007f", # nf-md-battery_60 + "\U000f0080", # nf-md-battery_70 + "\U000f0081", # nf-md-battery_80 + "\U000f0082", # nf-md-battery_90 + "\U000f0079", # nf-md-battery +] +bell = "\U000f009c" # nf-md-bell_outline +bell-slash = "\U000f009b" # nf-md-bell_off +blackberry = "\uf307" # nf-md-blackberry +bluetooth = "\U000f00af" # nf-md-bluetooth +calendar = "\U000f00ed" # nf-md-calendar +cogs = "\U000f0493" # nf-md-cog +cpu = [ + "\U000F0F86", # nf-md-speedometer_slow + "\U000F0F85", # nf-md-speedometer_medium + "\U000F04C5", # nf-md-speedometer +] +cpu_boost_on = "\U000f0521" # nf-md-toggle_switch +cpu_boost_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +disk_drive = "\U000f02ca" # nf-md-harddisk +docker = "\uf308" # nf-linux-docker +github = "\U000f02a4" # nf-md-github +gpu = "\U000f0379" # nf-md-monitor +headphones = "\U000f02cb" # nf-md-headphones +joystick = "\U000f0297" # nf-md-gamepad_variant +keyboard = "\U000f030c" # nf-md-keyboard +mail = "\U000f01ee" # nf-md-email +memory_mem = "\U000f035b" # nf-md-memory +memory_swap = "\U000f02ca" # nf-md-harddisk +mouse = "\U000f037d" # nf-md-mouse +music = "\U000f075a" # nf-md-music +music_next = "\U000f04ad" # nf-md-skip_next +music_pause = "\U000f03e4" # nf-md-pause +music_play = "\U000f040a" # nf-md-play +music_prev = "\U000f04ae" # nf-md-skip_previous +net_bridge = "\U000f04aa" # nf-md-sitemap +net_down = "\U000f01da" # nf-md-download +net_loopback = "\U000f006f" # nf-md-backup_restore +net_modem = "\U000f03f2" # nf-md-phone +net_cellular = [ + "\U000F08FD", # nf-md-network_strength_off_outline + "\U000F08FE", # nf-md-network_strength_outline + "\U000F08F4", # nf-md-network_strength_1 + "\U000F08F6", # nf-md-network_strength_2 + "\U000F08F8", # nf-md-network_strength_3 + "\U000F08FA", # nf-md-network_strength_4 +] +net_up = "\U000f0552" # nf-md-upload +net_vpn = "\U000f0582" # nf-md-vpn +net_wired = "\U000f0200" # nf-md-ethernet +net_wireless = [ + "\U000F092F", # nf-md-wifi_strength_outline + "\U000F091F", # nf-md-wifi_strength_1 + "\U000F0922", # nf-md-wifi_strength_2 + "\U000F0925", # nf-md-wifi_strength_3 + "\U000F0928", # nf-md-wifi_strength_4 +] +notification = "\U000f009c" # nf-md-bell_outline +phone = "\U000f03f2" # nf-md-phone +phone_disconnected = "\U000f0658" # nf-md-phone_minus +ping = "\U000f051f" # nf-md-timer_sand +pomodoro = "\ue001" # nf-pom-pomodoro_done +pomodoro_break = "\U000f0176" # nf-md-coffee +pomodoro_paused = "\U000f03e4" # nf-md-pause +pomodoro_started = "\U000f040a" # nf-md-play +pomodoro_stopped = "\U000f04db" # nf-md-stop +resolution = "\U000f0293" # nf-md-fullscreen +tasks = "\U000f05c7" # nf-md-playlist_check +tea = "\U000f0d9e" # nf-md-tea +thermometer = [ + "\U000f10c3", # nf-md-thermometer_low + "\U000f050f", # nf-md-thermometer + "\U000f10c2", # nf-md-thermometer_high +] +time = "\U000f0150" # nf-md-clock_outline +toggle_off = "\U000f0a19" # nf-md-toggle_switch_off_outline +toggle_on = "\U000f0521" # nf-md-toggle_switch +unknown = "\U000f0186" # nf-md-comment_question_outline | TODO: Make default? +update = "\U000f03d5" # nf-md-package_up +uptime = "\U000f0153" # nf-md-clock_in +volume_muted = "\U000f075f" # nf-md-volume_mute +volume = [ + "\U000f057f", # nf-md-volume_low + "\U000f0580", # nf-md-volume_medium + "\U000f057e", # nf-md-volume_high +] +microphone_muted = "\U000f036d" # nf-md-microphone_off +microphone = [ + "\U000f036e", # nf-md-microphone_outline + "\U000f036c", # nf-md-microphone + "\U000f036c", # nf-md-microphone +] +xrandr = "\U000f037a" # nf-md-monitor_multiple + +# Weather icons (the names the weather block documents) +weather_sun = "\ue30d" # nf-weather-day_sunny (when weather is reported as “Clear” during the day) +weather_moon = "\ue32b" # nf-weather-night_clear (when weather is reported as “Clear” at night) +weather_clouds = "\ue312" # nf-weather-cloudy (when weather is reported as “Clouds” during the day) +weather_clouds_night = "\ue37e" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night) +weather_fog = "\ue313" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day) +weather_fog_night = "\ue346" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night) +weather_rain = "\ue318" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day) +weather_rain_night = "\ue325" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night) +weather_snow = "\ue31a" # nf-weather-snow (when weather is reported as “Snow”) +weather_thunder = "\ue31d" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day) +weather_thunder_night = "\ue32a" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night) diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml new file mode 100644 index 0000000..1166036 --- /dev/null +++ b/modules/i3status-rust/files/top-bar.toml @@ -0,0 +1,33 @@ +# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime. +# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04. +icons_format = "{icon}" + +[theme] +theme = "plain" +[theme.overrides] +idle_bg = "#000000" +idle_fg = "#f1f1f1" +info_bg = "#000000" +info_fg = "#f1f1f1" +good_bg = "#000000" +good_fg = "#859900" +warning_bg = "#000000" +warning_fg = "#de5200" +critical_bg = "#000000" +critical_fg = "#ff0000" +separator = " | " +separator_fg = "#ffffff" +separator_bg = "#000000" + +[icons] +icons = "custom-icons" + +[[block]] +block = "focused_window" +[block.format] +full = " $title.str(max_w:50) |" +short = " $title.str(max_w:100) |" + +[[block]] +block = "uptime" +interval = 60 diff --git a/modules/i3status-rust/go.mod b/modules/i3status-rust/go.mod new file mode 100644 index 0000000..c13caf3 --- /dev/null +++ b/modules/i3status-rust/go.mod @@ -0,0 +1,5 @@ +module i3statusrust + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/i3status-rust/go.sum b/modules/i3status-rust/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/i3status-rust/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/i3status-rust/module.json b/modules/i3status-rust/module.json new file mode 100644 index 0000000..f0249c1 --- /dev/null +++ b/modules/i3status-rust/module.json @@ -0,0 +1,118 @@ +{ + "module": "i3status-rust", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-bar", + "scope": "node" + } + ], + "tools": [ + "i3status_rust_reload", + "i3status_rust_blocks", + "i3status_rust_block_run", + "i3status_rust_themes" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The bar watchdog (module i3status-rust, novox/hq ADR 0208): i3 never restarts a bar that dies; this\n# brings it back. Once per session, ending with the session's own process ($$).\n\"$HOME/.local/bin/i3bar-watchdog\" \"$$\" &\n" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "i3status-rust" + }, + { + "id": "update-check", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "icons-dir", + "type": "directory", + "path": "${machine:account-home}/.config/i3status-rust/icons", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "top-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n" + }, + { + "id": "bottom-bar", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: the battery and the GPU belong to a machine model's hardware module,\n# and a headset's address is not the bar's to know. Every block here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\nformat = \"$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + }, + { + "id": "icons", + "type": "file", + "path": "${machine:account-home}/.config/i3status-rust/icons/custom-icons.toml", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Icons for the bars (module i3status-rust, novox/hq ADR 0208), from the JetBrains Mono Nerd Font.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\n# Material from NerdFont\n# Codepoints from the Nerd Fonts cheat sheet\nbacklight = [\n \"\\ue38d\", # nf-weather-moon_new\n \"\\ue3d4\", # nf-weather-moon_alt_waxing_gibbous_6\n \"\\ue3d3\", # nf-weather-moon_alt_waxing_gibbous_5\n \"\\ue3d2\", # nf-weather-moon_alt_waxing_gibbous_4\n \"\\ue3d1\", # nf-weather-moon_alt_waxing_gibbous_3\n \"\\ue3d0\", # nf-weather-moon_alt_waxing_gibbous_2\n \"\\ue3cf\", # nf-weather-moon_alt_waxing_gibbous_1\n \"\\ue3ce\", # nf-weather-moon_alt_first_quarter\n \"\\ue3cd\", # nf-weather-moon_alt_waxing_crescent_6\n \"\\ue3cc\", # nf-weather-moon_alt_waxing_crescent_5\n \"\\ue3cb\", # nf-weather-moon_alt_waxing_crescent_4\n \"\\ue3ca\", # nf-weather-moon_alt_waxing_crescent_3\n \"\\ue3c9\", # nf-weather-moon_alt_waxing_crescent_2\n \"\\ue3c8\", # nf-weather-moon_alt_waxing_crescent_1\n \"\\ue39b\", # nf-weather-moon_full\n]\nbat_charging = \"\\U000f0084\" # nf-md-battery_charging\nbat_not_available = \"\\U000f0091\" # nf-md-battery_unknown\nbat = [\n \"\\U000f007a\", # nf-md-battery_10\n \"\\U000f007b\", # nf-md-battery_20\n \"\\U000f007c\", # nf-md-battery_30\n \"\\U000f007d\", # nf-md-battery_40\n \"\\U000f007e\", # nf-md-battery_50\n \"\\U000f007f\", # nf-md-battery_60\n \"\\U000f0080\", # nf-md-battery_70\n \"\\U000f0081\", # nf-md-battery_80\n \"\\U000f0082\", # nf-md-battery_90\n \"\\U000f0079\", # nf-md-battery\n]\nbell = \"\\U000f009c\" # nf-md-bell_outline\nbell-slash = \"\\U000f009b\" # nf-md-bell_off\nblackberry = \"\\uf307\" # nf-md-blackberry\nbluetooth = \"\\U000f00af\" # nf-md-bluetooth\ncalendar = \"\\U000f00ed\" # nf-md-calendar\ncogs = \"\\U000f0493\" # nf-md-cog\ncpu = [\n\t\"\\U000F0F86\", # nf-md-speedometer_slow\n\t\"\\U000F0F85\", # nf-md-speedometer_medium\n\t\"\\U000F04C5\", # nf-md-speedometer\n]\ncpu_boost_on = \"\\U000f0521\" # nf-md-toggle_switch\ncpu_boost_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ndisk_drive = \"\\U000f02ca\" # nf-md-harddisk\ndocker = \"\\uf308\" # nf-linux-docker\ngithub = \"\\U000f02a4\" # nf-md-github\ngpu = \"\\U000f0379\" # nf-md-monitor\nheadphones = \"\\U000f02cb\" # nf-md-headphones\njoystick = \"\\U000f0297\" # nf-md-gamepad_variant\nkeyboard = \"\\U000f030c\" # nf-md-keyboard\nmail = \"\\U000f01ee\" # nf-md-email\nmemory_mem = \"\\U000f035b\" # nf-md-memory\nmemory_swap = \"\\U000f02ca\" # nf-md-harddisk\nmouse = \"\\U000f037d\" # nf-md-mouse\nmusic = \"\\U000f075a\" # nf-md-music\nmusic_next = \"\\U000f04ad\" # nf-md-skip_next\nmusic_pause = \"\\U000f03e4\" # nf-md-pause\nmusic_play = \"\\U000f040a\" # nf-md-play\nmusic_prev = \"\\U000f04ae\" # nf-md-skip_previous\nnet_bridge = \"\\U000f04aa\" # nf-md-sitemap\nnet_down = \"\\U000f01da\" # nf-md-download\nnet_loopback = \"\\U000f006f\" # nf-md-backup_restore\nnet_modem = \"\\U000f03f2\" # nf-md-phone\nnet_cellular = [\n \"\\U000F08FD\", # nf-md-network_strength_off_outline\n \"\\U000F08FE\", # nf-md-network_strength_outline\n \"\\U000F08F4\", # nf-md-network_strength_1\n \"\\U000F08F6\", # nf-md-network_strength_2\n \"\\U000F08F8\", # nf-md-network_strength_3\n \"\\U000F08FA\", # nf-md-network_strength_4\n]\nnet_up = \"\\U000f0552\" # nf-md-upload\nnet_vpn = \"\\U000f0582\" # nf-md-vpn\nnet_wired = \"\\U000f0200\" # nf-md-ethernet\nnet_wireless = [\n\t\"\\U000F092F\", # nf-md-wifi_strength_outline\n\t\"\\U000F091F\", # nf-md-wifi_strength_1\n\t\"\\U000F0922\", # nf-md-wifi_strength_2\n\t\"\\U000F0925\", # nf-md-wifi_strength_3\n\t\"\\U000F0928\", # nf-md-wifi_strength_4\n]\nnotification = \"\\U000f009c\" # nf-md-bell_outline\nphone = \"\\U000f03f2\" # nf-md-phone\nphone_disconnected = \"\\U000f0658\" # nf-md-phone_minus\nping = \"\\U000f051f\" # nf-md-timer_sand\npomodoro = \"\\ue001\" # nf-pom-pomodoro_done\npomodoro_break = \"\\U000f0176\" # nf-md-coffee\npomodoro_paused = \"\\U000f03e4\" # nf-md-pause\npomodoro_started = \"\\U000f040a\" # nf-md-play\npomodoro_stopped = \"\\U000f04db\" # nf-md-stop\nresolution = \"\\U000f0293\" # nf-md-fullscreen\ntasks = \"\\U000f05c7\" # nf-md-playlist_check\ntea = \"\\U000f0d9e\" # nf-md-tea\nthermometer = [\n \"\\U000f10c3\", # nf-md-thermometer_low\n \"\\U000f050f\", # nf-md-thermometer\n \"\\U000f10c2\", # nf-md-thermometer_high\n]\ntime = \"\\U000f0150\" # nf-md-clock_outline\ntoggle_off = \"\\U000f0a19\" # nf-md-toggle_switch_off_outline\ntoggle_on = \"\\U000f0521\" # nf-md-toggle_switch\nunknown = \"\\U000f0186\" # nf-md-comment_question_outline | TODO: Make default?\nupdate = \"\\U000f03d5\" # nf-md-package_up\nuptime = \"\\U000f0153\" # nf-md-clock_in\nvolume_muted = \"\\U000f075f\" # nf-md-volume_mute\nvolume = [\n \"\\U000f057f\", # nf-md-volume_low\n \"\\U000f0580\", # nf-md-volume_medium\n \"\\U000f057e\", # nf-md-volume_high\n]\nmicrophone_muted = \"\\U000f036d\" # nf-md-microphone_off\nmicrophone = [\n\t\"\\U000f036e\", # nf-md-microphone_outline\n \"\\U000f036c\", # nf-md-microphone\n \"\\U000f036c\", # nf-md-microphone\n]\nxrandr = \"\\U000f037a\" # nf-md-monitor_multiple\n\n# Weather icons (the names the weather block documents)\nweather_sun = \"\\ue30d\" # nf-weather-day_sunny (when weather is reported as “Clear” during the day)\nweather_moon = \"\\ue32b\" # nf-weather-night_clear (when weather is reported as “Clear” at night)\nweather_clouds = \"\\ue312\" # nf-weather-cloudy (when weather is reported as “Clouds” during the day)\nweather_clouds_night = \"\\ue37e\" # nf-weather-night_alt_cloudy (when weather is reported as “Clouds” at night)\nweather_fog = \"\\ue313\" # nf-weather-fog (when weather is reported as “Fog” or “Mist” during the day)\nweather_fog_night = \"\\ue346\" # nf-weather-night_fog (when weather is reported as “Fog” or “Mist” at night)\nweather_rain = \"\\ue318\" # nf-weather-rain (when weather is reported as “Rain” or “Drizzle” during the day)\nweather_rain_night = \"\\ue325\" # nf-weather-night_alt_rain (when weather is reported as “Rain” or “Drizzle” at night)\nweather_snow = \"\\ue31a\" # nf-weather-snow (when weather is reported as “Snow”)\nweather_thunder = \"\\ue31d\" # nf-weather-thunderstorm (when weather is reported as “Thunderstorm” during the day)\nweather_thunder_night = \"\\ue32a\" # nf-weather-night_alt_thunderstorm (when weather is reported as “Thunderstorm” at night)\n" + }, + { + "id": "updates", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3status-updates", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# i3status-updates [threshold] (module i3status-rust, novox/hq ADR 0208): the pending updates, for\n# the bottom bar's custom block (json = true). Emits one JSON line.\n#\n# Adopted from the operator's pkg-updates of 2026-10-04. The native `packages` block showed a raw\n# red error on every transient network failure, which on a roaming laptop is often. This keeps the\n# last good result through a failure and shows an error only after `threshold` failures in a row\n# (default 10). Official packages through checkupdates (pacman-contrib, a temporary database, no\n# root); AUR packages through paru when it is installed, and none counted when it is not.\nset -uo pipefail\n\nthreshold=\"${1:-10}\"\nicon=\"update\"\nstate_dir=\"${XDG_RUNTIME_DIR:-/tmp}/i3status-updates\"\nmkdir -p \"$state_dir\"\nlast=\"$state_dir/last_good.json\"\nfailures=\"$state_dir/failures\"\n\njson() { printf '{\"icon\":\"%s\",\"state\":\"%s\",\"text\":\"%s\",\"short_text\":\"%s\"}' \"$icon\" \"$1\" \"$2\" \"$3\"; }\ncount() { [ -n \"$1\" ] && printf '%s\\n' \"$1\" | grep -c . || echo 0; }\n\nofficial=\"$(checkupdates 2>/dev/null)\"\nofficial_rc=$?\naur=\"\" aur_rc=0\nif command -v paru >/dev/null 2>&1; then\n\taur=\"$(paru -Qua 2>/dev/null)\"\n\taur_rc=$?\nfi\n\n# checkupdates: 0 updates listed, 2 none pending; anything else is a failure.\nif { [ \"$official_rc\" -eq 0 ] || [ \"$official_rc\" -eq 2 ]; } && [ \"$aur_rc\" -eq 0 ]; then\n\to=\"$(count \"$official\")\"\n\ta=\"$(count \"$aur\")\"\n\ttotal=$((o + a))\n\tif [ \"$total\" -eq 0 ]; then\n\t\tpayload=\"$(json Good \"up to date\" \"\")\"\n\telse\n\t\t# A kernel update wants a reboot: the one worth not putting off.\n\t\tstate=Info\n\t\tprintf '%s\\n' \"$official\" | grep -qE '^(linux|linux-lts|linux-zen) ' && state=Warning\n\t\tlabel=\"$o + $a = $total updates\"\n\t\t[ \"$a\" -eq 0 ] && label=\"$total updates\"\n\t\t[ \"$total\" -eq 1 ] && label=\"1 update\"\n\t\tpayload=\"$(json \"$state\" \"$label\" \"$total\")\"\n\tfi\n\tprintf '%s' \"$payload\" >\"$last\"\n\tprintf '0' >\"$failures\"\n\tprintf '%s\\n' \"$payload\"\n\texit 0\nfi\n\nn=$(($(cat \"$failures\" 2>/dev/null || echo 0) + 1))\nprintf '%s' \"$n\" >\"$failures\"\nif [ \"$n\" -ge \"$threshold\" ]; then\n\tjson Critical \"update check failing (${n}x)\" \"!\"\n\techo\nelif [ -s \"$last\" ]; then\n\tcat \"$last\"\n\techo\nelse\n\tjson Idle \"\" \"\"\n\techo\nfi\n" + }, + { + "id": "watchdog", + "type": "file", + "path": "${machine:account-home}/.local/bin/i3bar-watchdog", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" + }, + { + "id": "i3-bars", + "type": "file", + "path": "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/i3status-rust-tools", + "binary": "i3status-rust-tools", + "loads": [ + "i3status-rust-tools" + ] + } + ] + } +} diff --git a/modules/lemurs/README.md b/modules/lemurs/README.md new file mode 100644 index 0000000..47670ad --- /dev/null +++ b/modules/lemurs/README.md @@ -0,0 +1,101 @@ +# lemurs + +The login manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 3). + +- **Claims `node-login-manager`** and serves its verb `sessions`. +- **Package `lemurs`**, from the official repositories. It replaces the user repository's + `lemurs-git` that both workstations run. +- **Service `lemurs.service`, enabled at boot.** It is its own `display-manager.service` alias. Its + state is declared as nothing: a push never starts, stops or restarts the login manager, because + restarting it ends the session it started. A change to its configuration applies at its next + start (a reboot). +- **Gated by `package-manager`, `service-manager` and `seat`.** A machine without a display has + nothing to log into. + +## What it owns + +| path | class | what | +|---|---|---| +| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`config/config.toml`](config/config.toml), carried whole in the manifest | +| `/etc/lemurs/xsessions/`, `/etc/lemurs/wayland-sessions/` | owned, empty | where the configuration points lemurs for desktop entries, so none is offered | + +**Sessions are drop-ins.** The sessions offered are the executable files session modules place in +`/etc/lemurs/wms/` (X) and `/etc/lemurs/wayland/` (Wayland). The file's name is the session's name. +`i3` places `/etc/lemurs/wms/i3`, and `sway` will place its own in `wayland/`. The desktop entries +packages install (`/usr/share/xsessions/i3.desktop`, `i3-with-shmlog.desktop`) are no longer offered. +They start the window manager bare, skipping `~/.xinitrc`, which holds the environment, the +resources and every module's session lines. Today the workstations log in through exactly such an +entry (`i3`). It reaches the session's start only because `~/.xprofile` sources `~/.xinitrc`. + +**What the configuration changes** from the shipped file, each marked `mesh:` in it: + +- the two desktop-entry directories, as above; +- `switcher_visibility = "F3"`. The session switcher stays hidden as today, and F3 shows it once a + second session (sway) exists. + +Everything else is lemurs's default, which is also what runs today: X on `:1`, tty 2, the cache in +`/var/cache/lemurs`, `remember = true`. The workstations' current file is two releases old. The +running `lemurs-git` ignores its X keys and uses these defaults already, which is why X is on `:1` +although the file says `:0`. + +## Tools + +| tool | | what | +|---|---|---| +| `node-login-manager.sessions` | r | each session offered: name, X11 or Wayland, script or desktop entry, the file and what it runs, whether lemurs can run it (a script that is not executable is skipped); the default session and account from the cache | +| `lemurs_default_session` | r/a | the preselected session; set it to one that is offered. It writes the cache through `sudo -n`, and shows when lemurs next starts | +| `lemurs_logins` | r | logins from the journal (opened, closed, failed passwords), and which entry lemurs started each session with (its own log) | + +None needs the graphical session. + +## What it improves + +- the official package instead of a `-git` build from the user repository; +- the configuration in the format the binary reads. Today's file is mostly ignored, and the unmerged + `.pacnew` sits beside it; +- one session per session module, each starting through the session's start, and no bare desktop + entries; +- a dead entry gone: `/etc/lemurs/wms/i3wm` (`exec startx`) would start a second X server inside + the one lemurs started. + +## What it leaves found + +- `/etc/lemurs/xsetup.sh`, the package's; +- `/etc/pam.d/lemurs`, the package's (it unlocks the login keyring through `pam_gnome_keyring`); +- `/var/cache/lemurs`, lemurs's own. + +## The one-off migration (ADR 0182) + +1. **Replace the package by hand, once per workstation, at a moment you choose:** + `sudo pacman -S lemurs`, answering yes to removing `lemurs-git` (and `lemurs-git-debug` on the + laptop). The host cannot do this. `pacman -Q lemurs` answers with `lemurs-git`, which provides + `lemurs`, so the declared package already reads as installed. A non-interactive install would + also refuse the conflict. The binary is replaced on disk, and the running login manager keeps + running the old one until the next boot. +2. **Delete `/etc/lemurs/config.toml.pacnew`.** The module's file is that structure. +3. **Delete `/etc/lemurs/wms/i3wm`** once `i3` is assigned and `/etc/lemurs/wms/i3` exists. +4. **Delete `~/.xprofile`**, or its `. ~/.xinitrc` line (see `xorg`'s README). Until then the X setup + runs `~/.xinitrc` from `.xprofile` before it reaches the session's entry. That still works, once. + +Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`. + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| package | nothing until step 1 (`lemurs-git` reads as installed) | the same | +| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same | +| `/etc/lemurs/xsessions/`, `wayland-sessions/` | created, empty | the same | +| `lemurs.service` | already enabled: nothing | the same | +| the running login manager and session | **nothing** | **nothing** | +| next boot | the login screen offers `i3wm` until step 3, and `i3` once the `i3` module is assigned. It no longer offers the two desktop entries. The remembered session `i3` matches the `i3` module's entry by name | the same | + +**Order matters at one point:** assign `i3` before the next reboot after `lemurs`. Otherwise the +screen offers only `i3wm`, which runs `startx` inside lemurs's own X server and fails. Assigned in +to-be 42's order (`xorg`, `lemurs`, `i3` in one sitting), this cannot happen. + +## Blockers + +- **The package swap is a person's act** (step 1). The host's package resource cannot replace a + package that provides the same name. +- **No restart, by design.** A configuration change takes a reboot to show. diff --git a/modules/lemurs/cmd/lemurs-tools/lemurs_test.go b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go new file mode 100644 index 0000000..c9580cc --- /dev/null +++ b/modules/lemurs/cmd/lemurs-tools/lemurs_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "lemurs/internal/desktop" +) + +// aMachine lays out a login manager in a directory: its configuration naming directories under it, +// two X scripts (one not executable), a package's desktop entry and a cache. +func aMachine(t *testing.T) (lemurs, string, *[]string) { + root := t.TempDir() + for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} { + os.MkdirAll(filepath.Join(root, d), 0o755) + } + os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755) + os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644) + os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644) + os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644) + config := strings.Join([]string{ + `tty = 2`, `cache_path = "` + root + `/cache"`, + `[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`, + `[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`, + }, "\n") + os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644) + var ran []string + d := desktop.Desk{ + Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} }, + Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result { + line := strings.Join(append([]string{name}, args...), " ") + ran = append(ran, line+" <<"+string(stdin)) + if name == "journalctl" { + return desktop.Result{Stdout: journal} + } + return desktop.Result{} + }, + } + return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran +} + +const journal = `-- Boot a0 -- +2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file +2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op +2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0) +2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op +` + +func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) { + l, root, _ := aMachine(t) + got, err := l.sessions(context.Background(), desktop.Args{}) + if err != nil { + t.Fatal(err) + } + s := got.(map[string]any)["sessions"].([]Session) + if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" { + t.Fatalf("%+v", s) + } + if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered { + t.Fatalf("a script answers what it runs: %+v", s[1]) + } + if s[2].Name != "notes" || s[2].Executable || s[2].Offered { + t.Fatalf("a script that is not executable is not offered: %+v", s[2]) + } + if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" { + t.Fatalf("%+v", d) + } + _ = root +} + +func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml")) + if err != nil { + t.Fatal(err) + } + c := ParseConfig(string(raw)) + want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions", + WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2} + if c != want { + t.Fatalf("%+v", c) + } +} + +func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) { + l, root, ran := aMachine(t) + if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil { + t.Fatal("a session lemurs does not offer is refused") + } + if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil { + t.Fatal("an account that is not a name is refused") + } + got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"}) + if err != nil { + t.Fatal(err) + } + if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache < 1 { + c.Account = strings.TrimSpace(lines[1]) + } + return c, nil +} + +func (l lemurs) readConfig() (Config, error) { + b, err := os.ReadFile(l.config) + if err != nil { + return Config{}, fmt.Errorf("lemurs's configuration cannot be read (%v): is the lemurs module's package installed?", err) + } + return ParseConfig(string(b)), nil +} + +func (l lemurs) sessions(ctx context.Context, a desktop.Args) (any, error) { + c, err := l.readConfig() + if err != nil { + return nil, err + } + answer := map[string]any{"config": l.config, "sessions": ListSessions(c), "directories": c} + if cached, err := readCache(c.Cache); err == nil { + answer["default"] = cached + } else { + answer["default"] = nil + } + return answer, nil +} + +var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`) + +func (l lemurs) defaultSession(ctx context.Context, a desktop.Args) (any, error) { + c, err := l.readConfig() + if err != nil { + return nil, err + } + cached, _ := readCache(c.Cache) + want := a.Opt("session", "") + if want == "" { + return map[string]any{"default": cached, "cache": c.Cache}, nil + } + known := false + var names []string + for _, s := range ListSessions(c) { + if s.Offered { + names = append(names, s.Name) + known = known || s.Name == want + } + } + if !known { + sort.Strings(names) + return nil, fmt.Errorf("%q is not a session lemurs offers; it offers %s", want, strings.Join(names, ", ")) + } + account := a.Opt("account", cached.Account) + if account == "" { + account = os.Getenv("MESH_OPERATOR_ACCOUNT") + } + if !accountName.MatchString(account) { + return nil, fmt.Errorf("%q is not an account name", account) + } + content := []byte(want + "\n" + account + "\n") + var res desktop.Result + if l.uid == 0 { + res = l.d.Run(ctx, l.d.Base, content, "tee", c.Cache) + } else { + res = l.d.Run(ctx, l.d.Base, content, "sudo", "-n", "tee", c.Cache) + } + if !res.OK() { + return nil, fmt.Errorf("writing %s: %w", c.Cache, res.Err()) + } + return map[string]any{ + "default": Cached{Session: want, Account: account}, "was": cached, "cache": c.Cache, + "shown": "when lemurs next starts (a reboot, or the login manager restarted); lemurs rewrites it after each login when remember is on", + }, nil +} + +// Login is one event of the login screen. +type Login struct { + Time string `json:"time"` + Event string `json:"event"` // opened, closed or failed + Account string `json:"account,omitempty"` +} + +var ( + opened = regexp.MustCompile(`pam_unix\(lemurs:session\): session opened for user ([^(\s]+)`) + closed = regexp.MustCompile(`pam_unix\(lemurs:session\): session closed for user ([^(\s]+)`) + failed = regexp.MustCompile(`pam_unix\(lemurs:auth\): authentication failure;.*?user=(\S+)`) + started = regexp.MustCompile(`^\[(\S+) INFO\s+lemurs\] Starting new session for '([^']*)' in environment '(.*)'$`) +) + +// ParseJournal reads `journalctl -o short-iso` lines of lemurs into login events. +func ParseJournal(text string) []Login { + var out []Login + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) < 3 || strings.HasPrefix(line, "--") { + continue + } + for _, p := range []struct { + re *regexp.Regexp + event string + }{{opened, "opened"}, {closed, "closed"}, {failed, "failed"}} { + if m := p.re.FindStringSubmatch(line); m != nil { + out = append(out, Login{Time: f[0], Event: p.event, Account: m[1]}) + } + } + } + return out +} + +// Started is one session lemurs started, from its own log. +type Started struct { + Time string `json:"time"` + Account string `json:"account"` + Environment string `json:"environment"` +} + +// ParseStarts reads lemurs's own log for the sessions it started and which entry each ran. +func ParseStarts(text string) []Started { + var out []Started + for _, line := range strings.Split(text, "\n") { + if m := started.FindStringSubmatch(line); m != nil { + out = append(out, Started{Time: m[1], Account: m[2], Environment: m[3]}) + } + } + return out +} + +var since = regexp.MustCompile(`^[-+]?[0-9A-Za-z :.]{1,40}$`) + +func (l lemurs) logins(ctx context.Context, a desktop.Args) (any, error) { + from := a.Opt("since", "-7d") + if !since.MatchString(from) { + return nil, fmt.Errorf("since is a time journalctl reads, e.g. -7d or 2026-10-01") + } + limit, err := a.Whole("limit", 50, 1, 500) + if err != nil { + return nil, err + } + res := l.d.Plain(ctx, "journalctl", "_COMM=lemurs", "--since", from, "-o", "short-iso", "--no-pager", "-q") + if !res.OK() { + return nil, res.Err() + } + events := ParseJournal(res.Stdout) + cut := false + if len(events) > limit { + events, cut = events[len(events)-limit:], true + } + answer := map[string]any{"since": from, "events": events} + if cut { + answer["cut"] = true + } + if b, err := os.ReadFile(l.log); err == nil { + answer["started"] = ParseStarts(string(b)) + } + return answer, nil +} diff --git a/modules/lemurs/config/config.toml b/modules/lemurs/config/config.toml new file mode 100644 index 0000000..e60297c --- /dev/null +++ b/modules/lemurs/config/config.toml @@ -0,0 +1,368 @@ +# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced +# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with +# every option, as lemurs requires); what the mesh changes from it is marked "mesh:". +# +# The sessions offered are the executable files other modules place in the two scripts directories +# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there +# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are +# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is +# where the account's environment, the X resources and every module's session lines are. +# +# Lemurs configuration file. +# Contains all the customization options of lemurs. +# +# Note: that as of now you need to have all options in the selected +# configuration file. Otherwise Lemurs will not work. +# +# Colors: +# --------- +# There is a list of predefined colors. These include: +# - black +# - white +# - (dark) gray +# - (light) red +# - (light) blue +# - (light) green +# - (light) magenta +# - (light) cyan +# - (light) yellow +# - orange +# +# You can also utilize custom colors with hex color codes. +# "#87CEEB" will create a Sky Blue color. +# +# Note: If the color wasn't recognized, it will default to white. +# --------- +# +# Modifiers: +# --------- +# There is a number of modifiers you can use. These can be combined by +# delimiting them with a comma (e.g. "bold,italic"). The modifiers are: +# - bold +# - dim +# - italic +# - underlined +# - reverse +# - crossed out +# - hidden +# --------- +# + +# The tty which contains lemurs. This has to be mirrored in the lemurs.service +tty = 2 + +# Where to log the main lemurs control flow. +main_log_path = "/var/log/lemurs.log" + +# Where to log to for the client. The Client is the Desktop Environment or +# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY. +client_log_path = "/var/log/lemurs.client.log" + +# At which point to point the cache. If you want to disable the cache globally +# you can use `/dev/null`. +cache_path = "/var/cache/lemurs" + +# Disable all logging. This is overwritten by the `--no-log` flag. +do_log = true + +# The PAM service that should be used to login +pam_service = "lemurs" + +# Path to system shell that gets used to execute linux commands. In almost all +# cases, this should refer to a bash shell. +system_shell = "/bin/sh" + +# Initial state of the `PATH` environment variable. +initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin" + +# The type flag that will be appended to the shell that calls the session +# environment. This may depend on your shell. Options: +# - 'none'. Disables calling a login shell +# - 'short'. Produces the `-l` flag. Supported by most shells. +# - 'long'. This produces the `--login` flag and is suited for bash and zsh. +shell_login_flag = "short" + +# Focus behaviour of fields when Lemurs is initially started +# +# Possible values: +# - default: Initially focus on first non-cached value +# - no-focus: No initial focus +# - environment: Initially focus on the environment selector +# - username: Initially focus on the username field +# - password: Initially focus on the password field +focus_behaviour = "default" + +# General settings for background style +[background] + +# Control whether to render background widget or not +show_background = false + +[background.style] +# Allow to set the default background color for the login shell +color = "black" +# Settings for the background block's borders +show_border = true +border_color = "white" + +[power_controls] +# The margin between hints +hint_margin = 2 + +# There are no additional entries by default +entries = [] + +# Example +# Reboot to another os option +#[[power_controls.entries]] +## The text in the top-left to display how to reboot. +#hint = "Reboot to OS" +# +## The color and modifiers of the hint in the top-left corner +#hint_color = "dark gray" +#hint_modifiers = "" +# +## The key used to reboot. Possibilities are F1 to F12. +#key = "F3" +## The command that is executed when the key is pressed +#cmd = "efibootmgr -n0 && systemctl reboot -l" + + +# If you want to remove the base_entries +# base_entries = [] + +# Shutdown option +[[power_controls.base_entries]] +# The text in the top-left to display how to shutdown. +hint = "Shutdown" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to shutdown. Possibilities are F1 to F12. +key = "F1" +# The command that is executed when the key is pressed +cmd = "systemctl poweroff -l" + +# Reboot option +[[power_controls.base_entries]] +# The text in the top-left to display how to reboot. +hint = "Reboot" + +# The color and modifiers of the hint in the top-left corner +hint_color = "dark gray" +hint_modifiers = "" + +# The key used to reboot. Possibilities are F1 to F12. +key = "F2" +# The command that is executed when the key is pressed +cmd = "systemctl reboot -l" + +# Setting for the selector of the desktop environment you are using. +[environment_switcher] +# Terms: +# --------- +# Movers: indicators which show which direction one can move whilst selecting +# the desktop environment +# Selected: The currently selected desktop environment. +# Neighbours: The adjacent desktop environment to the one current selected +# +# Visualisation: +# +# < i3 bspwm awesome > +# +# ^ ^ ^ ^ ^ +# | | | | | +# mover | selected | mover +# | | +# neighbour neighbour +# --------- +# + +# Control the visibility of the switcher +# Options: +# - "visible" - Always show the switcher [default] +# - "hidden" - Always hide the switcher +# - [key] - F1-F12 to be able to toggle the visibility +# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen. +switcher_visibility = "F3" + +# The text in the top-left to display how to toggle the switcher. The text +# '%key%' will be replaced with the switcher_visibility key. This is not shown +# if switcher_visibility is set to "visible" or "hidden". +toggle_hint = "Switcher %key%" + +# The color and modifiers of the hint in the top-left corner +toggle_hint_color = "dark gray" +toggle_hint_modifiers = "" + + +# Show an option for the TTY shell when logging in as one of the environments. +# NOTE: it is always shown when no viable options are found. +include_tty_shell = false + +# Remember the selected environment after logging in for the next time +remember = true + +# Enables showing the movers +show_movers = true + +# Mover's color and modifiers whilst the selector is unfocused +mover_color = "dark gray" +mover_modifiers = "" + +# Mover's color and modifiers whilst the selector is focused +mover_color_focused = "orange" +mover_modifiers_focused = "bold" + +# The characters used to display the movers. Suggestions are: +# - "<" ">" +# - "<-" "->" +# - "<<" ">>" +# - "[" "]" +left_mover = "<" +right_mover = ">" + +# The margin between the movers and the neighbours or selected (depending on +# `show_neighbours`) +mover_margin = 1 + +# Enables showing the neighbours +show_neighbours = true + +# Neighbours' color and modifiers whilst the selector is unfocused +neighbour_color = "dark gray" +neighbour_modifiers = "" + +# Neighbours' color and modifiers whilst the selector is focused +neighbour_color_focused = "gray" +neighbour_modifiers_focused = "" + +# Margin between neighbours and selected +neighbour_margin = 1 + +# Selected's color and modifiers whilst the selector is unfocused +selected_color = "gray" +selected_modifiers = "underlined" + +# Selected's color and modifiers whilst the selector is focused +selected_color_focused = "white" +selected_modifiers_focused = "bold" + +# The length of the name of the desktop environment which is displayed. +max_display_length = 8 + +# The text used when no desktop environments are available +no_envs_text = "No environments..." + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is unfocused +no_envs_color = "white" +no_envs_modifiers = "" + +# The color and modifiers of the 'no desktop environments available text' +# whilst the selector is focused +no_envs_color_focused = "red" +no_envs_modifiers_focused = "" + +[username_field] + +# Remember the username for the next time after a successful login attempt. +remember = true + +[username_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Login" + +# The title's color and modifiers whilst the username field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the username field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the username field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the username field is focused +border_color_focused = "orange" + +# Constrain the width of the username field +use_max_width = true +# The constraint of the username field's width +max_width = 48 + +[password_field] + +# The character used for replacement when typing a password. Leave empty for no +# feedback. +# Note: Only one character is accepted. +content_replacement_character = "*" + +[password_field.style] +# Enables showing a title +show_title = true +# The text used within the title +title = "Password" + +# The title's color and modifiers whilst the password field is unfocused +title_color = "white" +content_color = "white" + +# The title's color and modifiers whilst the password field is focused +title_color_focused = "orange" +content_color_focused = "orange" + +# Enables showing the borders +show_border = true +# The borders' color and modifiers whilst the password field is unfocused +border_color = "white" +# The borders' color and modifiers whilst the password field is focused +border_color_focused = "orange" + +# Constrain the width of the password field +use_max_width = true +# The constraint of the password field's width +max_width = 48 + +[x11] +# Where to log to for the XServer. +xserver_log_path = "/var/log/lemurs.xorg.log" + +# The value of the `DISPLAY` environment variable for X11 sessions +x11_display = ":1" + +# How many seconds to give the X server to start. To make it infinitely, put it +# to 0. +xserver_timeout_secs = 60 + +# Where to find the X11 server binary +xserver_path = "/usr/bin/X" + +# Where to find the X11 xauth binary +xauth_path = "/usr/bin/xauth" + +# Path to the directory where the startup scripts for the X11 sessions are found +scripts_path = "/etc/lemurs/wms" + +# Path to the xsetup script that is needed for the environment setup of the +# window manager. +xsetup_path = "/etc/lemurs/xsetup.sh" + +# The directory to use for desktop entries X11 sessions. +# mesh: an empty directory of the module's own, so no package's desktop entry is offered. +xsessions_path = "/etc/lemurs/xsessions" + +[wayland] +# Path to the directory where the startup scripts for the Wayland sessions are +# found +scripts_path = "/etc/lemurs/wayland" + +# The directory to use for desktop entries wayland sessions. +# mesh: likewise for Wayland. +wayland_sessions_path = "/etc/lemurs/wayland-sessions" diff --git a/modules/lemurs/go.mod b/modules/lemurs/go.mod new file mode 100644 index 0000000..971dd9a --- /dev/null +++ b/modules/lemurs/go.mod @@ -0,0 +1,5 @@ +module lemurs + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/lemurs/go.sum b/modules/lemurs/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/lemurs/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/lemurs/internal/desktop/args.go b/modules/lemurs/internal/desktop/args.go new file mode 100644 index 0000000..d6be351 --- /dev/null +++ b/modules/lemurs/internal/desktop/args.go @@ -0,0 +1,160 @@ +package desktop + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans. +type Args map[string]any + +// Text is a required string, trimmed. +func (a Args) Text(name string) (string, error) { + v, ok := a[name].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is required, as text", name) + } + return strings.TrimSpace(v), nil +} + +// Opt is an optional string, trimmed, or def. +func (a Args) Opt(name, def string) string { + if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return def +} + +// Has is whether the caller gave the argument at all. +func (a Args) Has(name string) bool { + v, ok := a[name] + return ok && v != nil +} + +// Bool is an optional boolean: its value, and whether it was given. +func (a Args) Bool(name string) (bool, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return false, false, nil + } + b, isBool := v.(bool) + if !isBool { + return false, false, fmt.Errorf("%s is true or false", name) + } + return b, true, nil +} + +// Number is an optional number: its value, and whether it was given. +func (a Args) Number(name string) (float64, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return 0, false, nil + } + f, isNum := v.(float64) + if !isNum || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false, fmt.Errorf("%s is a number", name) + } + return f, true, nil +} + +// Whole is an optional whole number within [lo, hi], or def. +func (a Args) Whole(name string, def, lo, hi int) (int, error) { + f, given, err := a.Number(name) + if err != nil { + return 0, err + } + if !given { + return def, nil + } + if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi) + } + return int(f), nil +} + +// OneOf is an optional string that must be one of choices, or def. +func (a Args) OneOf(name, def string, choices ...string) (string, error) { + v := a.Opt(name, def) + for _, c := range choices { + if v == c { + return v, nil + } + } + return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", ")) +} + +// Strings is an optional list of strings. +func (a Args) Strings(name string) ([]string, error) { + v, ok := a[name] + if !ok || v == nil { + return nil, nil + } + list, isList := v.([]any) + if !isList { + return nil, fmt.Errorf("%s is a list of text", name) + } + out := make([]string, 0, len(list)) + for _, x := range list { + s, isText := x.(string) + if !isText { + return nil, fmt.Errorf("%s is a list of text", name) + } + out = append(out, s) + } + return out, nil +} + +// Home is the operator account's home: the runtime's word for it, else this process's. +func Home() string { + if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" { + return h + } + if h, err := os.UserHomeDir(); err == nil { + return h + } + return "/" +} + +// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path +// that leaves the home through `..` is refused, so a tool that writes never writes outside it. +func InHome(path string) (string, error) { + home := Home() + switch { + case path == "~": + path = home + case strings.HasPrefix(path, "~/"): + path = filepath.Join(home, path[2:]) + case !filepath.IsAbs(path): + path = filepath.Join(home, path) + } + path = filepath.Clean(path) + if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) { + return "", fmt.Errorf("%s is outside the account's home", path) + } + return path, nil +} + +// Schema builds a tool's input schema from property descriptions; required names those that must +// be given. A property is a string unless its description object says otherwise. +func Schema(props map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// Str, Num, Flag, List and Enum describe one property. +func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } +func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} } +func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} } +func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } +func List(desc string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} +} +func Enum(desc string, values ...string) map[string]any { + return map[string]any{"type": "string", "enum": values, "description": desc} +} diff --git a/modules/lemurs/internal/desktop/copies_test.go b/modules/lemurs/internal/desktop/copies_test.go new file mode 100644 index 0000000..d6bc1b3 --- /dev/null +++ b/modules/lemurs/internal/desktop/copies_test.go @@ -0,0 +1,42 @@ +package desktop + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// The desktop modules that carry this package. Each builds alone, so each has its own copy; this +// test, itself one of the copied files, holds them to one text wherever the siblings are present. +var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"} + +func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) { + mine, err := filepath.Glob("*.go") + if err != nil || len(mine) == 0 { + t.Fatal("no files of this package found", err) + } + compared := 0 + for _, module := range carriers { + dir := filepath.Join("..", "..", "..", module, "internal", "desktop") + if _, err := os.Stat(dir); err != nil { + continue + } + theirs, _ := filepath.Glob(filepath.Join(dir, "*.go")) + if len(theirs) != len(mine) { + t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine)) + continue + } + for _, f := range mine { + a, _ := os.ReadFile(f) + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(a, b) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/lemurs/internal/desktop/run.go b/modules/lemurs/internal/desktop/run.go new file mode 100644 index 0000000..cb9898c --- /dev/null +++ b/modules/lemurs/internal/desktop/run.go @@ -0,0 +1,232 @@ +package desktop + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that +// fits in a tool's reply. +const ( + DefaultTimeout = 10 * time.Second + MostOutput = 256 << 10 +) + +// Result is what one command did. +type Result struct { + Command []string `json:"command"` + Code int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` +} + +// OK is whether the command ran and exited 0. +func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut } + +// Err is the command's failure as an error naming it and what it said, or nil. +func (r Result) Err() error { + if r.OK() { + return nil + } + said := strings.TrimSpace(r.Stderr) + if said == "" { + said = strings.TrimSpace(r.Stdout) + } + if r.TimedOut { + return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " ")) + } + return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said) +} + +// Runner runs a command with an environment and answers what it did. Tools take one, so their +// tests replace the machine with a table of answers. +type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result + +// Exec is the machine's Runner: the command in its own process group, ended with everything it +// started at the deadline, each stream cut at MostOutput. +func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + } + res := Result{Command: append([]string{name}, args...)} + cmd := exec.Command(name, args...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + out, errb := &capped{}, &capped{} + cmd.Stdout, cmd.Stderr = out, errb + if err := cmd.Start(); err != nil { + res.Code = 127 + res.Stderr = err.Error() + return res + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-ctx.Done(): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + err = <-done + res.TimedOut = true + } + res.Stdout, res.Stderr = out.String(), errb.String() + res.Truncated = out.cut || errb.cut + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + res.Code = exit.ExitCode() + if res.Code < 0 { + res.Code = 128 + } + default: + res.Code = 1 + if res.Stderr == "" { + res.Stderr = err.Error() + } + } + return res +} + +// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an +// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write. +type capped struct { + buf bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.buf.Len(); room < len(p) { + if room > 0 { + c.buf.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.buf.Write(p) +} + +func (c *capped) String() string { return c.buf.String() } + +// Desk is what a desktop tool needs: how to find the session, and how to run a command. +type Desk struct { + Find func() (*Session, error) + Run Runner + // Base is the environment a command starts from, before the session's words. + Base []string +} + +// Machine is the real Desk, preferring the named processes as the session's. +func Machine(prefer ...string) Desk { + return Desk{ + Find: func() (*Session, error) { return Find(prefer...) }, + Run: Exec, + Base: os.Environ(), + } +} + +// InSession runs a command in the operator's session, or answers NoSession. +func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil +} + +// InSessionWith is InSession with standard input. +func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil +} + +// Plain runs a command with the base environment: for what needs no session. +func (d Desk) Plain(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, d.Base, nil, name, args...) +} + +// AsUser runs a command with the account's own runtime directory and bus, and no display. +func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...) +} + +// Launched is how a program was started in the session. +type Launched struct { + Unit string `json:"unit,omitempty"` + PID int `json:"pid,omitempty"` + How string `json:"how"` +} + +// Launch starts a program in the operator's session that outlives the call and the runtime. +// +// **Not as a child of this process.** The runtime is a system service; everything it starts is in +// its control group, and the service manager ends that group whenever the runtime restarts — which +// is every push that changes it. So the program is handed to the account's own service manager as a +// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the +// operator's user manager does. Without a user manager it is started detached as a last resort, and +// the answer says it will end with the runtime. +func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) { + if len(argv) == 0 { + return Launched{}, errors.New("nothing to launch") + } + env := s.Env(d.Base) + unit := "mesh-" + name + "-" + token() + args := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} { + if v := lookup(env, w); v != "" { + args = append(args, "--setenv="+w+"="+v) + } + } + args = append(args, "--") + args = append(args, argv...) + res := d.Run(ctx, env, nil, "systemd-run", args...) + if res.OK() { + return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil + } + if s.Bus != "" { + return Launched{}, res.Err() + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return Launched{}, err + } + pid := cmd.Process.Pid + go func() { _ = cmd.Wait() }() + return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil +} + +func lookup(env []string, name string) string { + for i := len(env) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(env[i], "="); ok && k == name { + return v + } + } + return "" +} + +func token() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/modules/lemurs/internal/desktop/session.go b/modules/lemurs/internal/desktop/session.go new file mode 100644 index 0000000..22d432e --- /dev/null +++ b/modules/lemurs/internal/desktop/session.go @@ -0,0 +1,445 @@ +// Package desktop is how a desktop module's tools act in the operator's graphical session +// (novox/hq ADR 0208, research 026/05). +// +// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a +// process of node-tools.service, started by the system's service manager as the operator account, +// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in +// was started elsewhere, by the login manager, and the only place its values are written down is +// the environment of the processes it started. So this package finds the session the way a person +// would: it looks at the operator account's own processes, takes the one that is plainly the +// session's (the window manager, or the oldest process carrying a display), confirms with logind +// that its session is a live local one, and checks that the display's socket is really there. +// +// **Only the session's own words are read.** A session's processes also carry whatever its start +// script exported — on the workstations that was a file of secrets — so the environment is filtered +// to a fixed list of names while it is read, and nothing else ever leaves /proc. +// +// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`, +// whenever it exists, because that is where the portal, the notifier and every user service +// listen. A session started on a private bus (a stale session, measured on one workstation) is +// reported as `session_bus` beside it, so the difference is visible rather than guessed at. +// +// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm, +// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change +// every copy together — the modules' tests compare them. +package desktop + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// SessionWords are the only environment words read from a session's process: the ones that say +// where the session is. Everything else in that environment is the operator's, and is never read. +var SessionWords = []string{ + "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", + "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR", + "I3SOCK", "SWAYSOCK", +} + +// Session is the operator's running graphical session, as a tool needs it. +type Session struct { + UID int `json:"uid"` + ID string `json:"session_id,omitempty"` + Type string `json:"type"` + Display string `json:"display,omitempty"` + WaylandDisplay string `json:"wayland_display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + RuntimeDir string `json:"runtime_dir"` + Bus string `json:"bus,omitempty"` + SessionBus string `json:"session_bus,omitempty"` + Desktop string `json:"desktop,omitempty"` + // FoundIn is the process whose environment named the session. + FoundIn Process `json:"found_in"` + // Active is logind's word on the session, when logind answered. + Active *bool `json:"active,omitempty"` + + words map[string]string +} + +// Process is one process the search looked at. +type Process struct { + PID int `json:"pid"` + Command string `json:"command"` + start uint64 +} + +// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool +// that returns it as its error still answers structured data. +type NoSession struct { + Reason string `json:"reason"` + Looked []string `json:"looked"` +} + +func (e *NoSession) Error() string { + b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked}) + return string(b) +} + +// IsNoSession is whether err says there is no session. +func IsNoSession(err error) bool { + var n *NoSession + return errors.As(err, &n) +} + +// Finder holds where the search looks, so a test can point it at a tree of its own. +type Finder struct { + Proc string // the process table: /proc + X11Sockets string // where X servers listen: /tmp/.X11-unix + RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user + UID int // whose session + // Prefer names the processes that are the session's own, best first: the session's holder. + Prefer []string + // Logind answers `loginctl show-session` for one id; nil skips the check. + Logind func(id string) (map[string]string, error) +} + +// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the +// operator account the runtime names (MESH_OPERATOR_ACCOUNT). +func DefaultFinder(prefer ...string) Finder { + uid := os.Getuid() + if uid == 0 { + if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" { + if u, err := user.Lookup(name); err == nil { + if n, err := strconv.Atoi(u.Uid); err == nil { + uid = n + } + } + } + } + return Finder{ + Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user", + UID: uid, Prefer: prefer, Logind: loginctl, + } +} + +// Find is the operator's session on this machine, preferring a process named in prefer. +func Find(prefer ...string) (*Session, error) { + return DefaultFinder(prefer...).Find() +} + +type candidate struct { + proc Process + words map[string]string + rank int + logind map[string]string +} + +// Find looks for the session. +func (f Finder) Find() (*Session, error) { + entries, err := os.ReadDir(f.Proc) + if err != nil { + return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}} + } + looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)} + var found []candidate + stale := 0 + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(f.Proc, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID { + continue + } + words := readWords(filepath.Join(dir, "environ")) + if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" { + continue + } + if !f.reachable(words) { + stale++ + continue + } + found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words}) + } + if len(found) == 0 { + reason := fmt.Sprintf("no process of uid %d carries a display", f.UID) + if stale > 0 { + reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID) + } + return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)} + } + + // logind's word on each session the candidates name, asked once per session. + asked := map[string]map[string]string{} + for i := range found { + id := found[i].words["XDG_SESSION_ID"] + if f.Logind == nil || id == "" { + found[i].rank = 1 + continue + } + props, done := asked[id] + if !done { + props, _ = f.Logind(id) + asked[id] = props + } + found[i].logind = props + switch { + case props == nil: + found[i].rank = 1 + case props["Remote"] == "yes": + found[i].rank = 3 + case props["Active"] == "yes" && props["State"] != "closing": + found[i].rank = 0 + case props["State"] == "closing": + found[i].rank = 3 + default: + found[i].rank = 2 + } + } + if f.Logind != nil { + looked = append(looked, "logind's sessions") + } + preferred := func(c candidate) int { + for i, p := range f.Prefer { + if c.proc.Command == p { + return i + } + } + return len(f.Prefer) + } + sort.SliceStable(found, func(i, j int) bool { + a, b := found[i], found[j] + if a.rank != b.rank { + return a.rank < b.rank + } + if pa, pb := preferred(a), preferred(b); pa != pb { + return pa < pb + } + if a.proc.start != b.proc.start { + return a.proc.start < b.proc.start + } + return a.proc.PID < b.proc.PID + }) + best := found[0] + if best.rank == 3 { + return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked} + } + return f.session(best), nil +} + +func (f Finder) session(c candidate) *Session { + w := c.words + s := &Session{ + UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"], + XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w, + } + s.Desktop = w["XDG_CURRENT_DESKTOP"] + if s.Desktop == "" { + s.Desktop = w["XDG_SESSION_DESKTOP"] + } + switch { + case w["XDG_SESSION_TYPE"] != "": + s.Type = w["XDG_SESSION_TYPE"] + case s.WaylandDisplay != "": + s.Type = "wayland" + default: + s.Type = "x11" + } + if s.RuntimeDir == "" { + s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + if isSocket(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus { + s.SessionBus = own + } + if c.logind != nil { + active := c.logind["Active"] == "yes" + s.Active = &active + } + return s +} + +// reachable is whether the display a process names is still served: the X server's socket, or the +// Wayland compositor's. A process outliving its session still carries the session's words. +func (f Finder) reachable(w map[string]string) bool { + if d := w["WAYLAND_DISPLAY"]; d != "" { + path := d + if !filepath.IsAbs(d) { + dir := w["XDG_RUNTIME_DIR"] + if dir == "" { + dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + path = filepath.Join(dir, d) + } + if isSocket(path) { + return true + } + } + n, ok := DisplayNumber(w["DISPLAY"]) + return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n))) +} + +// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on +// another host — an ssh session's forwarded one — is not the local session and answers false. +func DisplayNumber(display string) (int, bool) { + host, rest, ok := strings.Cut(display, ":") + if !ok || (host != "" && host != "unix") { + return 0, false + } + num, _, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(num) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// Word is one of the session's words as its process had it ("" when it had none). +func (s *Session) Word(name string) string { return s.words[name] } + +// Env is base with the session's words in place of whatever base said for them. +func (s *Session) Env(base []string) []string { + drop := map[string]bool{} + for _, w := range SessionWords { + drop[w] = true + } + out := make([]string, 0, len(base)+8) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if !drop[k] { + out = append(out, kv) + } + } + bus := s.Bus + if bus == "" { + bus = s.SessionBus + } + for _, kv := range [][2]string{ + {"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority}, + {"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus}, + {"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID}, + {"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]}, + {"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]}, + {"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]}, + } { + if kv[1] != "" { + out = append(out, kv[0]+"="+kv[1]) + } + } + return out +} + +// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the +// user manager or the session bus and needs no display — it works with no session at all. +func UserEnv(base []string, uid int) []string { + dir := filepath.Join("/run/user", strconv.Itoa(uid)) + out := make([]string, 0, len(base)+2) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" { + out = append(out, kv) + } + } + return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus")) +} + +// readWords reads a process's environment and keeps only SessionWords. +func readWords(path string) map[string]string { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + keep := map[string]bool{} + for _, w := range SessionWords { + keep[w] = true + } + out := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + k, v, ok := bytes.Cut(kv, []byte{'='}) + if ok && keep[string(k)] { + out[string(k)] = string(v) + } + } + return out +} + +func comm(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "comm")) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// startTime is field 22 of /proc//stat: when the process started, in clock ticks since boot. +// Read after the command's closing parenthesis, because the command may hold spaces. +func startTime(dir string) uint64 { + b, err := os.ReadFile(filepath.Join(dir, "stat")) + if err != nil { + return ^uint64(0) + } + i := bytes.LastIndexByte(b, ')') + if i < 0 { + return ^uint64(0) + } + fields := strings.Fields(string(b[i+1:])) + // fields[0] is the state, field 3 of the line; start time is field 22. + if len(fields) < 20 { + return ^uint64(0) + } + n, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return ^uint64(0) + } + return n +} + +func isSocket(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +// loginctl asks logind about one session, by its property lines. +func loginctl(id string) (map[string]string, error) { + cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class") + var out bytes.Buffer + cmd.Stdout = &out + done := make(chan error, 1) + if err := cmd.Start(); err != nil { + return nil, err + } + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + return nil, err + } + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + return nil, errors.New("loginctl did not answer in 3s") + } + return ParseProperties(out.String()), nil +} + +// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them. +func ParseProperties(text string) map[string]string { + out := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok { + out[k] = v + } + } + return out +} diff --git a/modules/lemurs/internal/desktop/session_test.go b/modules/lemurs/internal/desktop/session_test.go new file mode 100644 index 0000000..119c16d --- /dev/null +++ b/modules/lemurs/internal/desktop/session_test.go @@ -0,0 +1,255 @@ +package desktop + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// A machine in a directory: a process table, the X servers' socket directory and a runtime base. +type fakeMachine struct { + t *testing.T + proc, x11, runtime string + uid int +} + +func newMachine(t *testing.T) *fakeMachine { + root, err := os.MkdirTemp("", "desk") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(root) }) + m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()} + for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + return m +} + +func (m *fakeMachine) socket(path string) { + l, err := net.Listen("unix", path) + if err != nil { + m.t.Fatal(err) + } + m.t.Cleanup(func() { l.Close() }) +} + +func (m *fakeMachine) process(pid int, comm string, start int, env ...string) { + dir := filepath.Join(m.proc, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + m.t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600) + os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644) + // pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime + // cutime cstime priority nice threads itrealvalue starttime ... + stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0" + os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644) +} + +func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder { + return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind} +} + +func active(id string) (map[string]string, error) { + return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil +} + +func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X1")) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "bus")) + m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1") + m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1", + "XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run, + "DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret") + m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate + + s, err := m.finder(active, "i3").Find() + if err != nil { + t.Fatal(err) + } + if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" { + t.Fatalf("session: %+v", s) + } + if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" { + t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus) + } + if s.Active == nil || !*s.Active { + t.Fatal("logind's word is carried") + } + env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n") + for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} { + if !strings.Contains(env, want) { + t.Errorf("env lacks %s:\n%s", want, env) + } + } + if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") { + t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env) + } + b, _ := json.Marshal(s) + if strings.Contains(string(b), "secret") { + t.Fatal("the answer carries a word outside the session's") + } +} + +func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3") + m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3") + s, err := m.finder(nil).Find() + if err != nil || s.FoundIn.PID != 400 { + t.Fatalf("%+v %v", s, err) + } + if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" { + t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s) + } +} + +func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) { + m := newMachine(t) + m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket + _, err := m.finder(active, "i3").Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") { + t.Fatalf("%v", err) + } + var answer map[string]any + if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" { + t.Fatalf("the refusal is structured: %s", err) + } +} + +func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) { + m := newMachine(t) + m.process(600, "sshd", 1, "SSH_CONNECTION=x") + _, err := m.finder(active).Find() + if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") { + t.Fatalf("%v", err) + } +} + +func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) { + m := newMachine(t) + m.socket(filepath.Join(m.x11, "X0")) + m.socket(filepath.Join(m.x11, "X1")) + m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a") + m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b") + logind := func(id string) (map[string]string, error) { + if id == "a" { + return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil + } + return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil + } + s, err := m.finder(logind, "i3").Find() + if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" { + t.Fatalf("the active session: %+v %v", s, err) + } + remote := func(string) (map[string]string, error) { + return map[string]string{"Active": "yes", "Remote": "yes"}, nil + } + if _, err := m.finder(remote).Find(); !IsNoSession(err) { + t.Fatalf("a remote session is not the operator's desktop: %v", err) + } +} + +func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) { + m := newMachine(t) + run := filepath.Join(m.runtime, strconv.Itoa(m.uid)) + m.socket(filepath.Join(run, "wayland-1")) + m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock") + s, err := m.finder(nil, "sway").Find() + if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" { + t.Fatalf("%+v %v", s, err) + } + if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") { + t.Fatal("the compositor's socket word passes") + } +} + +func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) { + for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} { + if _, ok := DisplayNumber(d); ok != want { + t.Errorf("%q: %v", d, ok) + } + } +} + +func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) { + r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat") + if !r.OK() || r.Stdout != "hello" { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3") + if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here") + if r.OK() || r.Code != 127 { + t.Fatalf("%+v", r) + } + r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero") + if !r.Truncated || len(r.Stdout) != MostOutput { + t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated) + } +} + +func TestArgumentsAreReadStrictly(t *testing.T) { + a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}} + if v, err := a.Text("name"); err != nil || v != "x" { + t.Fatal(v, err) + } + if _, err := a.Text("missing"); err == nil { + t.Fatal("a missing required text") + } + if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 { + t.Fatal(n, err) + } + if _, err := a.Whole("f", 0, 0, 5); err == nil { + t.Fatal("1.5 is not whole") + } + if _, err := a.Whole("n", 0, 4, 5); err == nil { + t.Fatal("out of range") + } + if b, given, err := a.Bool("b"); !b || !given || err != nil { + t.Fatal("bool") + } + if _, _, err := a.Bool("name"); err == nil { + t.Fatal("text is not a bool") + } + if l, err := a.Strings("l"); err != nil || len(l) != 2 { + t.Fatal(l, err) + } + if _, err := a.OneOf("name", "", "y", "z"); err == nil { + t.Fatal("not one of") + } +} + +func TestAPathIsKeptInsideTheHome(t *testing.T) { + t.Setenv("MESH_OPERATOR_HOME", "/home/op") + for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} { + if got, err := InHome(in); err != nil || got != want { + t.Errorf("%s: %s %v", in, got, err) + } + } + for _, out := range []string{"/etc/passwd", "~/../other", "../x"} { + if _, err := InHome(out); err == nil { + t.Errorf("%s was accepted", out) + } + } +} + +func TestPropertiesAreParsed(t *testing.T) { + p := ParseProperties("Active=yes\nState=active\nDisplay=\n") + if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" { + t.Fatal(p) + } +} diff --git a/modules/lemurs/module.json b/modules/lemurs/module.json new file mode 100644 index 0000000..1c79a7c --- /dev/null +++ b/modules/lemurs/module.json @@ -0,0 +1,69 @@ +{ + "module": "lemurs", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager", + "seat" + ], + "claims": [ + { + "name": "node-login-manager", + "scope": "node", + "serves": [ + "sessions" + ] + } + ], + "tools": [ + "lemurs_default_session", + "lemurs_logins" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "lemurs" + }, + { + "id": "config", + "type": "file", + "path": "/etc/lemurs/config.toml", + "mode": "0644", + "content": "# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced\n# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with\n# every option, as lemurs requires); what the mesh changes from it is marked \"mesh:\".\n#\n# The sessions offered are the executable files other modules place in the two scripts directories\n# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there\n# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are\n# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is\n# where the account's environment, the X resources and every module's session lines are.\n#\n# Lemurs configuration file.\n# Contains all the customization options of lemurs.\n#\n# Note: that as of now you need to have all options in the selected\n# configuration file. Otherwise Lemurs will not work.\n#\n# Colors:\n# ---------\n# There is a list of predefined colors. These include:\n# - black\n# - white\n# - (dark) gray\n# - (light) red\n# - (light) blue\n# - (light) green\n# - (light) magenta\n# - (light) cyan\n# - (light) yellow\n# - orange\n#\n# You can also utilize custom colors with hex color codes.\n# \"#87CEEB\" will create a Sky Blue color.\n#\n# Note: If the color wasn't recognized, it will default to white.\n# ---------\n#\n# Modifiers:\n# ---------\n# There is a number of modifiers you can use. These can be combined by\n# delimiting them with a comma (e.g. \"bold,italic\"). The modifiers are:\n# - bold\n# - dim\n# - italic\n# - underlined\n# - reverse\n# - crossed out\n# - hidden\n# ---------\n#\n\n# The tty which contains lemurs. This has to be mirrored in the lemurs.service\ntty = 2\n\n# Where to log the main lemurs control flow.\nmain_log_path = \"/var/log/lemurs.log\"\n\n# Where to log to for the client. The Client is the Desktop Environment or\n# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.\nclient_log_path = \"/var/log/lemurs.client.log\"\n\n# At which point to point the cache. If you want to disable the cache globally\n# you can use `/dev/null`.\ncache_path = \"/var/cache/lemurs\"\n\n# Disable all logging. This is overwritten by the `--no-log` flag.\ndo_log = true\n\n# The PAM service that should be used to login\npam_service = \"lemurs\"\n\n# Path to system shell that gets used to execute linux commands. In almost all\n# cases, this should refer to a bash shell.\nsystem_shell = \"/bin/sh\"\n\n# Initial state of the `PATH` environment variable.\ninitial_path = \"/usr/local/sbin:/usr/local/bin:/usr/bin\"\n\n# The type flag that will be appended to the shell that calls the session\n# environment. This may depend on your shell. Options:\n# - 'none'. Disables calling a login shell\n# - 'short'. Produces the `-l` flag. Supported by most shells.\n# - 'long'. This produces the `--login` flag and is suited for bash and zsh.\nshell_login_flag = \"short\"\n\n# Focus behaviour of fields when Lemurs is initially started\n#\n# Possible values:\n# - default: Initially focus on first non-cached value\n# - no-focus: No initial focus\n# - environment: Initially focus on the environment selector\n# - username: Initially focus on the username field\n# - password: Initially focus on the password field\nfocus_behaviour = \"default\"\n\n# General settings for background style\n[background]\n\n# Control whether to render background widget or not\nshow_background = false\n\n[background.style]\n# Allow to set the default background color for the login shell\ncolor = \"black\"\n# Settings for the background block's borders\nshow_border = true\nborder_color = \"white\"\n\n[power_controls]\n# The margin between hints\nhint_margin = 2\n\n# There are no additional entries by default\nentries = []\n\n# Example\n# Reboot to another os option\n#[[power_controls.entries]]\n## The text in the top-left to display how to reboot.\n#hint = \"Reboot to OS\"\n#\n## The color and modifiers of the hint in the top-left corner\n#hint_color = \"dark gray\"\n#hint_modifiers = \"\"\n#\n## The key used to reboot. Possibilities are F1 to F12.\n#key = \"F3\"\n## The command that is executed when the key is pressed\n#cmd = \"efibootmgr -n0 && systemctl reboot -l\"\n\n\n# If you want to remove the base_entries\n# base_entries = []\n\n# Shutdown option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to shutdown.\nhint = \"Shutdown\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to shutdown. Possibilities are F1 to F12.\nkey = \"F1\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl poweroff -l\"\n\n# Reboot option\n[[power_controls.base_entries]]\n# The text in the top-left to display how to reboot.\nhint = \"Reboot\"\n\n# The color and modifiers of the hint in the top-left corner\nhint_color = \"dark gray\"\nhint_modifiers = \"\"\n\n# The key used to reboot. Possibilities are F1 to F12.\nkey = \"F2\"\n# The command that is executed when the key is pressed\ncmd = \"systemctl reboot -l\"\n\n# Setting for the selector of the desktop environment you are using.\n[environment_switcher]\n# Terms:\n# ---------\n# Movers: indicators which show which direction one can move whilst selecting\n# the desktop environment\n# Selected: The currently selected desktop environment.\n# Neighbours: The adjacent desktop environment to the one current selected\n#\n# Visualisation:\n#\n# < i3 bspwm awesome >\n#\n# ^ ^ ^ ^ ^\n# | | | | |\n# mover | selected | mover\n# | |\n# neighbour neighbour\n# ---------\n#\n\n# Control the visibility of the switcher\n# Options:\n# - \"visible\" - Always show the switcher [default]\n# - \"hidden\" - Always hide the switcher\n# - [key] - F1-F12 to be able to toggle the visibility\n# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.\nswitcher_visibility = \"F3\"\n\n# The text in the top-left to display how to toggle the switcher. The text\n# '%key%' will be replaced with the switcher_visibility key. This is not shown\n# if switcher_visibility is set to \"visible\" or \"hidden\".\ntoggle_hint = \"Switcher %key%\"\n\n# The color and modifiers of the hint in the top-left corner\ntoggle_hint_color = \"dark gray\"\ntoggle_hint_modifiers = \"\"\n\n\n# Show an option for the TTY shell when logging in as one of the environments.\n# NOTE: it is always shown when no viable options are found.\ninclude_tty_shell = false\n\n# Remember the selected environment after logging in for the next time\nremember = true\n\n# Enables showing the movers\nshow_movers = true\n\n# Mover's color and modifiers whilst the selector is unfocused\nmover_color = \"dark gray\"\nmover_modifiers = \"\"\n\n# Mover's color and modifiers whilst the selector is focused\nmover_color_focused = \"orange\"\nmover_modifiers_focused = \"bold\"\n\n# The characters used to display the movers. Suggestions are:\n# - \"<\" \">\"\n# - \"<-\" \"->\"\n# - \"<<\" \">>\"\n# - \"[\" \"]\"\nleft_mover = \"<\"\nright_mover = \">\"\n\n# The margin between the movers and the neighbours or selected (depending on\n# `show_neighbours`)\nmover_margin = 1\n\n# Enables showing the neighbours\nshow_neighbours = true\n\n# Neighbours' color and modifiers whilst the selector is unfocused\nneighbour_color = \"dark gray\"\nneighbour_modifiers = \"\"\n\n# Neighbours' color and modifiers whilst the selector is focused\nneighbour_color_focused = \"gray\"\nneighbour_modifiers_focused = \"\"\n\n# Margin between neighbours and selected\nneighbour_margin = 1\n\n# Selected's color and modifiers whilst the selector is unfocused\nselected_color = \"gray\"\nselected_modifiers = \"underlined\"\n\n# Selected's color and modifiers whilst the selector is focused\nselected_color_focused = \"white\"\nselected_modifiers_focused = \"bold\"\n\n# The length of the name of the desktop environment which is displayed.\nmax_display_length = 8\n\n# The text used when no desktop environments are available\nno_envs_text = \"No environments...\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is unfocused\nno_envs_color = \"white\"\nno_envs_modifiers = \"\"\n\n# The color and modifiers of the 'no desktop environments available text'\n# whilst the selector is focused\nno_envs_color_focused = \"red\"\nno_envs_modifiers_focused = \"\"\n\n[username_field]\n\n# Remember the username for the next time after a successful login attempt.\nremember = true\n\n[username_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Login\"\n\n# The title's color and modifiers whilst the username field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the username field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the username field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the username field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the username field\nuse_max_width = true\n# The constraint of the username field's width\nmax_width = 48\n\n[password_field]\n\n# The character used for replacement when typing a password. Leave empty for no\n# feedback.\n# Note: Only one character is accepted.\ncontent_replacement_character = \"*\"\n\n[password_field.style]\n# Enables showing a title\nshow_title = true\n# The text used within the title\ntitle = \"Password\"\n\n# The title's color and modifiers whilst the password field is unfocused\ntitle_color = \"white\"\ncontent_color = \"white\"\n\n# The title's color and modifiers whilst the password field is focused\ntitle_color_focused = \"orange\"\ncontent_color_focused = \"orange\"\n\n# Enables showing the borders\nshow_border = true\n# The borders' color and modifiers whilst the password field is unfocused\nborder_color = \"white\"\n# The borders' color and modifiers whilst the password field is focused\nborder_color_focused = \"orange\"\n\n# Constrain the width of the password field\nuse_max_width = true\n# The constraint of the password field's width\nmax_width = 48\n\n[x11]\n# Where to log to for the XServer.\nxserver_log_path = \"/var/log/lemurs.xorg.log\"\n\n# The value of the `DISPLAY` environment variable for X11 sessions\nx11_display = \":1\"\n\n# How many seconds to give the X server to start. To make it infinitely, put it\n# to 0.\nxserver_timeout_secs = 60\n\n# Where to find the X11 server binary\nxserver_path = \"/usr/bin/X\"\n\n# Where to find the X11 xauth binary\nxauth_path = \"/usr/bin/xauth\"\n\n# Path to the directory where the startup scripts for the X11 sessions are found\nscripts_path = \"/etc/lemurs/wms\"\n\n# Path to the xsetup script that is needed for the environment setup of the\n# window manager.\nxsetup_path = \"/etc/lemurs/xsetup.sh\"\n\n# The directory to use for desktop entries X11 sessions.\n# mesh: an empty directory of the module's own, so no package's desktop entry is offered.\nxsessions_path = \"/etc/lemurs/xsessions\"\n\n[wayland]\n# Path to the directory where the startup scripts for the Wayland sessions are\n# found\nscripts_path = \"/etc/lemurs/wayland\"\n\n# The directory to use for desktop entries wayland sessions.\n# mesh: likewise for Wayland.\nwayland_sessions_path = \"/etc/lemurs/wayland-sessions\"\n" + }, + { + "id": "xsessions", + "type": "directory", + "path": "/etc/lemurs/xsessions", + "mode": "0755" + }, + { + "id": "wayland-sessions", + "type": "directory", + "path": "/etc/lemurs/wayland-sessions", + "mode": "0755" + }, + { + "id": "service", + "type": "service", + "unit": "lemurs.service", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/lemurs-tools", + "binary": "lemurs-tools", + "loads": [ + "lemurs-tools" + ] + } + ] + } +} diff --git a/modules/picom/README.md b/modules/picom/README.md new file mode 100644 index 0000000..827215f --- /dev/null +++ b/modules/picom/README.md @@ -0,0 +1,62 @@ +# picom + +The X compositor as a module (novox/hq ADR 0208, research 026). + +- Installs `picom`, and `xorg-xprop` for setting a window's own opacity. +- Claims the mesh's `node-compositor` seat (no verbs yet, ADR 0208 §2) and requires `x11-display`. + That requirement has the machine's reach: the display server must be held on this module's own + machine, or assignment is refused, naming the seat's holders. +- Owns `~/.config/picom/` and `~/.config/picom/picom.conf`, which it writes whole at every push. +- **Adds no start of its own.** picom's package ships an XDG autostart entry + (`/etc/xdg/autostart/picom.desktop`), which the session runs: the `i3` module's `dex --autostart`. + That is the tool's own grain (ADR 0208 §4) and its one start. The desktop modules follow one rule: + a process has one starter. Its package's autostart entry is that starter where there is one, and + the `xinitrc` slot where there is none. + +## Tools + +Served by the node's runtime as the operator account (ADR 0175). The tools find the operator's X +session from the window manager's own environment, and say so plainly when nobody is logged in. + +| tool | does | +|---|---| +| `picom_restart` | a running picom re-reads its file (`SIGUSR1`); `hard`, or none running, starts a fresh one | +| `picom_rules` | the global options and the window rules in force, in picom's order, and whether it runs | +| `picom_window_opacity` | read or set one window's own opacity, the focused window by default | +| `picom_toggle` | compositing off or on, for a game or a test; the next login starts it again | + +A picom a tool starts runs under the account's own service manager (`systemd-run --user`, unit +`picom`). As a child of the runtime it would die whenever the runtime restarts. + +## What it improves on what was found + +- **Window rules** replace `opacity-rule`, `inactive-opacity` and `inactive-dim`, which picom 12 and + later supersede. The behaviour is the same: only terminals are translucent (95 % focused, 75 % + unfocused). The browser and video "pin to 100 %" rule went, because nothing else is made + translucent any more. +- **Full-screen windows are opaque,** a terminal included. +- **One start.** Today both the window manager's configuration and the autostart entry start picom, + and the second one exits. +- On the desktop, picom was not running at all on the day it was measured, although both starts were + in place. It probably fails to start on that machine's GPU. `picom_restart` with `hard` after + assignment answers with what happened. The next step is the `picom` user unit's journal. + +## What it leaves as found + +The window manager's own `exec --no-startup-id picom` line belongs to the `i3` module's configuration, +which no longer carries it. Nothing else of picom's lies outside the directory this module owns. + +## Migration (ADR 0182) + +- The first push keeps the found `picom.conf` once, then writes the module's. +- Until the `i3` module replaces the hand-kept `~/.config/i3/config`, its `exec picom` line starts a + second picom. The second one exits at once, because a compositor is already running. Nothing to + do, unless the line is still there after `i3` is assigned. + +## Blockers + +- The seat `node-compositor` and the provision `x11-display` are ADR 0208's. + Until the controller knows them, `mctl` reads the claim and the requirement as unknown, and refuses + the module. +- `xorg-xprop` is declared here because the `xorg` module does not install it. If `xorg` comes to + declare it, it leaves this module, since a package is declared once per node. diff --git a/modules/picom/cmd/picom-tools/args.go b/modules/picom/cmd/picom-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/picom/cmd/picom-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/picom/cmd/picom-tools/main.go b/modules/picom/cmd/picom-tools/main.go new file mode 100644 index 0000000..379cd56 --- /dev/null +++ b/modules/picom/cmd/picom-tools/main.go @@ -0,0 +1,89 @@ +// picom's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the compositor's own tools, served +// by the node's runtime as the operator account. node-compositor has no verbs yet (ADR 0208 §2), so +// every tool here is the module's own. +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: "picom_restart", + Description: "Make the compositor re-read its configuration: a running picom is told to reinitialise " + + "(SIGUSR1); with hard, or when none runs, a fresh one is started in the operator's session under " + + "their service manager. Answers what was done and the pids.", + Input: map[string]any{ + "hard": map[string]any{"type": "boolean", "description": "end the running picom and start a new one (default false)"}, + }, + Run: func(args map[string]any) (any, error) { + hard, err := flag(args, "hard", false) + if err != nil { + return nil, err + } + return Restart(hard) + }, + }, + { + Name: "picom_rules", + Description: "The compositor's configuration in force: its global options and each window rule " + + "(match, opacity, shadow, fade…) in the order picom applies them, from the file the mesh placed, " + + "and whether picom is running.", + Run: func(map[string]any) (any, error) { return Rules(configPath()) }, + }, + { + Name: "picom_window_opacity", + Description: "Read or set one window's own opacity (_NET_WM_WINDOW_OPACITY), the focused window " + + "unless one is named. A window rule that sets opacity (terminals) outranks it. Lasts as long as " + + "the window; the declared rules are untouched.", + Input: map[string]any{ + "window": map[string]any{"type": "string", "description": "X window id, 0x… or decimal (default: the focused window)"}, + "opacity": map[string]any{"type": "integer", "description": "0-100 to set; 100 removes the window's own value; omit to read"}, + }, + Run: func(args map[string]any) (any, error) { + window, err := text(args, "window", false) + if err != nil { + return nil, err + } + if _, set := args["opacity"]; !set { + return WindowOpacity(window, -1) + } + opacity, err := whole(args, "opacity", 100, 0, 100) + if err != nil { + return nil, err + } + return WindowOpacity(window, opacity) + }, + }, + { + Name: "picom_toggle", + Description: "Turn compositing off or on in the operator's session, for a game or a test: off ends " + + "picom, on starts it. Without `on`, flips it. The session start brings it back at the next login.", + Input: map[string]any{ + "on": map[string]any{"type": "boolean", "description": "true to start, false to stop (default: the opposite of now)"}, + }, + Run: func(args map[string]any) (any, error) { + var want *bool + if _, given := args["on"]; given { + on, err := flag(args, "on", false) + if err != nil { + return nil, err + } + want = &on + } + return Toggle(want) + }, + }, + } +} diff --git a/modules/picom/cmd/picom-tools/manifest_helpers_test.go b/modules/picom/cmd/picom-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/picom/cmd/picom-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/picom/cmd/picom-tools/manifest_test.go b/modules/picom/cmd/picom-tools/manifest_test.go new file mode 100644 index 0000000..6f67e50 --- /dev/null +++ b/modules/picom/cmd/picom-tools/manifest_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// picom's shape (novox/hq ADR 0208): it claims node-compositor and serves no verb of it, requires the +// X display on its own machine, owns its configuration file, and is started once, by its package's +// XDG autostart entry, never by the window manager or the session's start as well. + +func TestItClaimsTheCompositorSeatAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "picom" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-compositor", Scope: "node"}}) { + 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{"picom", "xorg-xprop"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestItOwnsItsConfigurationAsWrittenInTheModule(t *testing.T) { + m := readManifest(t) + m.sameAsSource(t, "configuration", "files/picom.conf") + if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/picom/picom.conf" { + t.Fatalf("path: %v", p) + } + if d := m.resource(t, "configuration-dir"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/picom" { + t.Fatalf("the directory: %v", d) + } +} + +func TestThePackagesAutostartEntryIsItsOnlyStart(t *testing.T) { + m := readManifest(t) + // picom's package ships /etc/xdg/autostart/picom.desktop, which the session's dex runs. A second + // start from here would be a second compositor, refused by the first. + if m.Shell != nil { + t.Fatalf("a session-start line as well as the package's autostart entry: %+v", m.Shell) + } + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") || strings.Contains(p, "autostart") { + t.Fatalf("a second start: %v", r) + } + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("a unit: %v", r) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/picom/cmd/picom-tools/picom.go b/modules/picom/cmd/picom-tools/picom.go new file mode 100644 index 0000000..c719df2 --- /dev/null +++ b/modules/picom/cmd/picom-tools/picom.go @@ -0,0 +1,293 @@ +package main + +import ( + "errors" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +// unit is the name picom runs under in the account's service manager when a tool starts it. The +// session start runs its own, and a tool that starts one first ends any other (Toggle, Restart). +const unit = "picom" + +func configPath() string { return filepath.Join(operatorHome(), ".config", "picom", "picom.conf") } + +// Running is picom's processes of this account. +func running() []int { return processesOf("picom") } + +// RestartResult is what picom_restart answers. +type RestartResult struct { + Action string `json:"action"` + PIDs []int `json:"pids"` + Note string `json:"note,omitempty"` +} + +// Restart tells a running picom to reinitialise, or starts one. +func Restart(hard bool) (RestartResult, error) { + if pids := running(); len(pids) > 0 && !hard { + return RestartResult{Action: "reinitialised", PIDs: signalAll("picom", syscall.SIGUSR1), + Note: "picom re-read " + configPath()}, nil + } + s, err := findSession() + if err != nil { + return RestartResult{}, err + } + if err := stopAll(); err != nil { + return RestartResult{}, err + } + if err := s.detach(unit, "picom", "--config", configPath()); err != nil { + return RestartResult{}, err + } + pids := waitFor(func() []int { return running() }, 3*time.Second) + return RestartResult{Action: "started", PIDs: pids, + Note: "started under the account's service manager as " + unit + ".service"}, nil +} + +// stopAll ends every picom of this account and waits for them to go. +func stopAll() error { + signalAll("picom", syscall.SIGTERM) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if len(running()) == 0 { + return nil + } + time.Sleep(100 * time.Millisecond) + } + if left := running(); len(left) > 0 { + return fmt.Errorf("picom %v did not end within 3s", left) + } + return nil +} + +func waitFor(get func() []int, within time.Duration) []int { + deadline := time.Now().Add(within) + for { + if got := get(); len(got) > 0 || time.Now().After(deadline) { + return got + } + time.Sleep(100 * time.Millisecond) + } +} + +// ToggleResult is what picom_toggle answers. +type ToggleResult struct { + Compositing bool `json:"compositing"` + PIDs []int `json:"pids"` + Note string `json:"note"` +} + +// Toggle stops or starts compositing; want nil flips it. +func Toggle(want *bool) (ToggleResult, error) { + on := len(running()) == 0 + if want != nil { + on = *want + } + if !on { + if err := stopAll(); err != nil { + return ToggleResult{}, err + } + return ToggleResult{Compositing: false, PIDs: []int{}, + Note: "picom ended; the session start runs it again at the next login, or call picom_toggle with on"}, nil + } + if pids := running(); len(pids) > 0 { + return ToggleResult{Compositing: true, PIDs: pids, Note: "picom was already running"}, nil + } + r, err := Restart(true) + if err != nil { + return ToggleResult{}, err + } + return ToggleResult{Compositing: len(r.PIDs) > 0, PIDs: r.PIDs, Note: r.Note}, nil +} + +// Rule is one window rule, its options as written. +type Rule struct { + Match string `json:"match"` + Options map[string]string `json:"options"` +} + +// RulesResult is what picom_rules answers. +type RulesResult struct { + File string `json:"file"` + Running []int `json:"running"` + Global map[string]string `json:"global"` + Rules []Rule `json:"rules"` + // Legacy names options that window rules supersede, which picom ignores when rules are set. + Legacy []string `json:"legacy,omitempty"` +} + +var ( + commentLine = regexp.MustCompile(`(?m)^\s*#.*$`) + rulesBlock = regexp.MustCompile(`(?s)\brules\s*[=:]\s*\((.*?)\)\s*;`) + ruleGroup = regexp.MustCompile(`(?s)\{(.*?)\}`) + assignment = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9-]*)\s*[=:]\s*(.+?)\s*;?\s*$`) +) + +// superseded are the options picom's window rules replace (picom(1), WINDOW RULES). +var superseded = []string{"opacity-rule", "inactive-opacity", "active-opacity", "inactive-dim", + "shadow-exclude", "fade-exclude", "focus-exclude", "rounded-corners-exclude", "blur-background-exclude", + "corner-radius-rules", "wintypes", "inactive-opacity-override", "mark-wmwin-focused"} + +// Rules reads picom's configuration file into its global options and its window rules. +func Rules(path string) (RulesResult, error) { + raw, err := os.ReadFile(path) + if err != nil { + return RulesResult{}, fmt.Errorf("picom's configuration: %w", err) + } + return parseRules(path, string(raw), running()), nil +} + +func parseRules(path, conf string, pids []int) RulesResult { + out := RulesResult{File: path, Running: pids, Global: map[string]string{}, Rules: []Rule{}} + conf = commentLine.ReplaceAllString(conf, "") + rest := conf + if m := rulesBlock.FindStringSubmatchIndex(conf); m != nil { + body := conf[m[2]:m[3]] + rest = conf[:m[0]] + conf[m[1]:] + for _, g := range ruleGroup.FindAllStringSubmatch(body, -1) { + r := Rule{Options: map[string]string{}} + for _, part := range strings.Split(g[1], ";") { + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if k == "match" { + r.Match = unquote(v) + } else { + r.Options[k] = unquote(v) + } + } + out.Rules = append(out.Rules, r) + } + } + for _, m := range assignment.FindAllStringSubmatch(rest, -1) { + out.Global[m[1]] = unquote(strings.TrimSuffix(m[2], ";")) + } + if len(out.Rules) > 0 { + for _, name := range superseded { + if _, set := out.Global[name]; set { + out.Legacy = append(out.Legacy, name) + } + } + } + return out +} + +func unquote(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + return v[1 : len(v)-1] + } + return v +} + +// OpacityResult is what picom_window_opacity answers. +type OpacityResult struct { + Window string `json:"window"` + Class string `json:"class,omitempty"` + Title string `json:"title,omitempty"` + // Opacity is the window's own value in percent; nil when it has none. + Opacity *int `json:"opacity"` + Note string `json:"note,omitempty"` +} + +var ( + activeWindow = regexp.MustCompile(`window id # (0x[0-9a-fA-F]+)`) + cardinal = regexp.MustCompile(`_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`) + wmClass = regexp.MustCompile(`WM_CLASS\(STRING\) = "[^"]*", "([^"]*)"`) + wmName = regexp.MustCompile(`_NET_WM_NAME\(UTF8_STRING\) = "(.*)"`) +) + +// WindowOpacity reads a window's own opacity, and sets it when percent is 0-100. +func WindowOpacity(window string, percent int) (OpacityResult, error) { + s, err := findSession() + if err != nil { + return OpacityResult{}, err + } + if window == "" { + r, err := s.run(5*time.Second, "", "xprop", "-root", "_NET_ACTIVE_WINDOW") + if err != nil { + return OpacityResult{}, err + } + m := activeWindow.FindStringSubmatch(r.Stdout) + if m == nil || m[1] == "0x0" { + return OpacityResult{}, errors.New("no window is focused") + } + window = m[1] + } + id, err := windowID(window) + if err != nil { + return OpacityResult{}, err + } + if percent >= 0 { + var r Result + if percent == 100 { + r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-remove", "_NET_WM_WINDOW_OPACITY") + } else { + r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-f", "_NET_WM_WINDOW_OPACITY", "32c", + "-set", "_NET_WM_WINDOW_OPACITY", strconv.FormatUint(opacityCardinal(percent), 10)) + } + if err != nil { + return OpacityResult{}, err + } + if r.Code != 0 { + return OpacityResult{}, fmt.Errorf("xprop: %s", strings.TrimSpace(r.Stderr)) + } + } + r, err := s.run(5*time.Second, "", "xprop", "-id", id, "_NET_WM_WINDOW_OPACITY", "WM_CLASS", "_NET_WM_NAME") + if err != nil { + return OpacityResult{}, err + } + if r.Code != 0 { + return OpacityResult{}, fmt.Errorf("window %s: %s", id, strings.TrimSpace(r.Stderr)) + } + out := parseWindow(id, r.Stdout) + if rules, err := Rules(configPath()); err == nil && out.Class != "" { + for _, rule := range rules.Rules { + if _, sets := rule.Options["opacity"]; sets && strings.Contains(rule.Match, "class_g = '"+out.Class+"'") { + out.Note = "a window rule sets the opacity of class " + out.Class + " and outranks the window's own value" + } + } + } + return out, nil +} + +func windowID(w string) (string, error) { + w = strings.TrimSpace(strings.ToLower(w)) + base := 10 + if strings.HasPrefix(w, "0x") { + w, base = w[2:], 16 + } + n, err := strconv.ParseUint(w, base, 32) + if err != nil || n == 0 { + return "", fmt.Errorf("window %q is not an X window id", w) + } + return fmt.Sprintf("0x%x", n), nil +} + +func opacityCardinal(percent int) uint64 { + return uint64(math.Round(float64(percent) / 100 * float64(math.MaxUint32))) +} + +func parseWindow(id, out string) OpacityResult { + r := OpacityResult{Window: id} + if m := cardinal.FindStringSubmatch(out); m != nil { + n, _ := strconv.ParseUint(m[1], 10, 64) + p := int(math.Round(float64(n) / float64(math.MaxUint32) * 100)) + r.Opacity = &p + } + if m := wmClass.FindStringSubmatch(out); m != nil { + r.Class = m[1] + } + if m := wmName.FindStringSubmatch(out); m != nil { + r.Title = m[1] + } + return r +} diff --git a/modules/picom/cmd/picom-tools/picom_test.go b/modules/picom/cmd/picom-tools/picom_test.go new file mode 100644 index 0000000..102ad3e --- /dev/null +++ b/modules/picom/cmd/picom-tools/picom_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A pid above the kernel's largest, so a signal a test sends reaches nothing. +const nobody = 4194400 + +func TestTheModulesOwnConfigurationReadsAsRulesInOrder(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf")) + if err != nil { + t.Fatal(err) + } + r := parseRules("picom.conf", string(raw), nil) + if r.Global["backend"] != "glx" || r.Global["vsync"] != "true" || r.Global["shadow"] != "false" { + t.Fatalf("global options: %v", r.Global) + } + if len(r.Rules) != 5 { + t.Fatalf("five rules: %+v", r.Rules) + } + focused, unfocused := r.Rules[2], r.Rules[3] + if focused.Match != "class_g = 'XTerm' && focused" || focused.Options["opacity"] != "0.95" || + unfocused.Match != "class_g = 'XTerm' && !focused" || unfocused.Options["opacity"] != "0.75" { + t.Fatalf("the terminal rules: %+v %+v", focused, unfocused) + } + if last := r.Rules[4]; last.Match != "fullscreen" || last.Options["opacity"] != "1" { + t.Fatalf("full screen is opaque, and last so it wins: %+v", last) + } + if len(r.Legacy) != 0 { + t.Fatalf("the module's file sets no option the rules supersede: %v", r.Legacy) + } + if _, inGlobal := r.Global["match"]; inGlobal { + t.Fatal("a rule's key leaked into the global options") + } +} + +func TestTodaysFileIsReportedForTheOptionsItsRulesWouldIgnore(t *testing.T) { + conf := "inactive-opacity = 1.0;\nopacity-rule = [ \"95:class_g = 'XTerm'\" ];\nrules = (\n { match = \"focused\"; opacity = 1; }\n);\n" + r := parseRules("x", conf, []int{7}) + if len(r.Rules) != 1 || r.Rules[0].Match != "focused" || len(r.Running) != 1 { + t.Fatalf("%+v", r) + } + if strings.Join(r.Legacy, ",") != "opacity-rule,inactive-opacity" { + t.Fatalf("legacy: %v", r.Legacy) + } +} + +func TestAWindowIdIsHexOrDecimalAndNeverZero(t *testing.T) { + for in, want := range map[string]string{"0x3C00012": "0x3c00012", "62914578": "0x3c00012"} { + if got, err := windowID(in); err != nil || got != want { + t.Errorf("%s: %s, %v", in, got, err) + } + } + for _, bad := range []string{"0", "0x0", "window", "-1", "0x1ffffffff"} { + if _, err := windowID(bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } +} + +func TestOpacityIsAPercentOfTheFullCardinal(t *testing.T) { + if opacityCardinal(0) != 0 || opacityCardinal(100) != 0xffffffff || opacityCardinal(75) != 3221225471 { + t.Fatalf("%d %d %d", opacityCardinal(0), opacityCardinal(100), opacityCardinal(75)) + } + r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY(CARDINAL) = 3221225471\nWM_CLASS(STRING) = \"xterm\", \"XTerm\"\n_NET_WM_NAME(UTF8_STRING) = \"op@host: ~\"\n") + if r.Opacity == nil || *r.Opacity != 75 || r.Class != "XTerm" || r.Title != "op@host: ~" { + t.Fatalf("%+v", r) + } + if r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY: not found.\n"); r.Opacity != nil { + t.Fatalf("no value of its own: %+v", r) + } +} + +func TestSettingTheFocusedWindowsOpacityAsksXpropAndNamesTheRuleThatOutranksIt(t *testing.T) { + root := fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + conf := filepath.Join(root, "home", ".config", "picom") + if err := os.MkdirAll(conf, 0o755); err != nil { + t.Fatal(err) + } + src, _ := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf")) + if err := os.WriteFile(filepath.Join(conf, "picom.conf"), src, 0o644); err != nil { + t.Fatal(err) + } + bin := fakeBinaries(t, map[string]string{"xprop": `echo "xprop $* DISPLAY=$DISPLAY" >> "$LOG" +case "$*" in + "-root _NET_ACTIVE_WINDOW") echo "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00012" ;; + *WM_CLASS*) printf '_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483648\nWM_CLASS(STRING) = "xterm", "XTerm"\n' ;; +esac`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + r, err := WindowOpacity("", 50) + if err != nil { + t.Fatal(err) + } + if r.Window != "0x3c00012" || r.Opacity == nil || *r.Opacity != 50 || !strings.Contains(r.Note, "XTerm") { + t.Fatalf("%+v", r) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "xprop -id 0x3c00012 -f _NET_WM_WINDOW_OPACITY 32c -set _NET_WM_WINDOW_OPACITY 2147483648 DISPLAY=:1") { + t.Fatalf("xprop was asked:\n%s", log) + } +} + +func TestWithoutASessionTheToolsThatDrawSaySo(t *testing.T) { + fakeMachine(t) + if _, err := WindowOpacity("", -1); !errors.Is(err, ErrNoSession) { + t.Fatalf("window opacity: %v", err) + } + on := true + if _, err := Toggle(&on); !errors.Is(err, ErrNoSession) { + t.Fatalf("toggle on: %v", err) + } +} + +func TestARunningPicomIsToldToReinitialiseNotRestarted(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "picom", "DISPLAY=:1") + r, err := Restart(false) + if err != nil || r.Action != "reinitialised" { + t.Fatalf("%+v, %v", r, err) + } + on := true + if r, err := Toggle(&on); err != nil || !r.Compositing || r.PIDs[0] != nobody { + t.Fatalf("on while running: %+v, %v", r, err) + } +} diff --git a/modules/picom/cmd/picom-tools/session.go b/modules/picom/cmd/picom-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/picom/cmd/picom-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/picom/cmd/picom-tools/session_test.go b/modules/picom/cmd/picom-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/picom/cmd/picom-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/picom/files/picom.conf b/modules/picom/files/picom.conf new file mode 100644 index 0000000..efe5ff2 --- /dev/null +++ b/modules/picom/files/picom.conf @@ -0,0 +1,32 @@ +# picom, the X compositor (module picom, 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 and moved to +# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim. + +backend = "glx"; +vsync = true; + +fading = true; +fade-in-step = 0.05; +fade-out-step = 0.05; + +# Tiled windows hide their shadows and corners, so neither is drawn. +shadow = false; +corner-radius = 0; +blur-method = "none"; + +# "Focused" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach +# the toplevel picom tracks, and a terminal then never lifts to its focused opacity. +use-ewmh-active-win = true; + +# Window rules are applied in order, and a later match wins. Only terminals are translucent: +# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class; +# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is +# used wherever no rule sets one, which is what the window-opacity tool sets. +rules = ( + { match = "window_type = 'tooltip'"; fade = false; opacity = 0.95; }, + { match = "window_type = 'dock' || window_type = 'desktop'"; fade = false; }, + { match = "class_g = 'XTerm' && focused"; opacity = 0.95; }, + { match = "class_g = 'XTerm' && !focused"; opacity = 0.75; }, + # A full-screen window is opaque, a terminal included: a video or a game is never see-through. + { match = "fullscreen"; opacity = 1; } +); diff --git a/modules/picom/go.mod b/modules/picom/go.mod new file mode 100644 index 0000000..c30ad61 --- /dev/null +++ b/modules/picom/go.mod @@ -0,0 +1,5 @@ +module picom + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/picom/go.sum b/modules/picom/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/picom/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/picom/module.json b/modules/picom/module.json new file mode 100644 index 0000000..e7dc836 --- /dev/null +++ b/modules/picom/module.json @@ -0,0 +1,64 @@ +{ + "module": "picom", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-compositor", + "scope": "node" + } + ], + "tools": [ + "picom_restart", + "picom_rules", + "picom_window_opacity", + "picom_toggle" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "picom" + }, + { + "id": "window-properties", + "type": "package", + "package": "xorg-xprop" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/picom", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/picom/picom.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\nbackend = \"glx\";\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/picom-tools", + "binary": "picom-tools", + "loads": [ + "picom-tools" + ] + } + ] + } +} 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" + ] + } + ] + } +} diff --git a/modules/screen-lock/README.md b/modules/screen-lock/README.md new file mode 100644 index 0000000..1b3e442 --- /dev/null +++ b/modules/screen-lock/README.md @@ -0,0 +1,73 @@ +# screen-lock + +The lock screen, idle timeouts and display power as one module (novox/hq ADR 0208, research +026/04). + +- Installs `xss-lock` and the distribution's `i3lock`. Claims the mesh's `node-lock-screen` seat and + serves its verb `lock`. Requires `x11-display` on its own machine. +- **Declares absent** (ADR 0180), as replaced and not coming back: + - `i3lock-color`, the colour build from the user repository; + - `xscreensaver`, a second screensaver that was installed and deliberately never started. +- Places the locker `~/.local/bin/screen-lock`. The lock key (`$mod+Delete`) is the `i3` module's. + It asks logind to lock and names no locker, so it does not depend on which module holds the seat. +- At session start, through the `xinitrc` slot `normal`, it: + - sets the timeouts: lock after 30 minutes idle, displays to standby and suspend at 30 minutes and + off at 60; + - starts `xss-lock --transfer-sleep-lock`, which runs the locker on idle, before suspend and on + logind's Lock, so the lock key, a closed lid and a suspend all lead to one locker. + + xss-lock stays out of the units on purpose. It must find its own login session, and a user unit + runs in the service manager's session instead, where xss-lock silently finds none. + +## Tools + +| tool | does | +|---|---| +| `node-lock-screen.lock` | lock now, through logind, so the session's one locker answers; answers since when | +| `screen_lock_idle` | the idle and display power timeouts in force; change any of them for this session | +| `screen_lock_inhibit` | keep the screen on and unlocked for N minutes, then restore; 0 ends it early | +| `screen_lock_locked` | locked or not and since when, whether xss-lock runs, whether an inhibition holds | + +An inhibition runs under the account's service manager (`screen-lock-inhibit.service`). Stopping it +restores the timeouts at once. It holds off the idle lock and display power only: a lock asked for by +hand, by the lid or before suspend still locks. + +## Decided: the distribution's i3lock now + +The colour build lives in the user repository, and the colours are the only difference. The module +uses the official `i3lock` (black, failed attempts shown, an empty Enter ignored). The colour build +can come back as a pinned archive of this module (ADR 0205). That is a follow-up, and only the +locker's options change with it. + +## What it improves on what was found + +- **The machine no longer waits for the unlock to suspend.** The found wrapper started i3lock with + xss-lock's sleep lock inherited, so a suspend was held until logind's delay ran out. The new locker + follows xss-lock's own pattern: the lock is released as soon as i3lock is up. +- **One locker.** A second lock while locked does nothing. The power menu locks through logind + instead of starting its own i3lock. +- **The respawn loop ends with the session.** The found loop kept retrying every two seconds after + logout. +- **The timeouts are set once, in one place.** On the laptop, measured on 2026-10-04, the screensaver + timeout in force was 600 s, not the 1800 s the start script asked for. + +## What it leaves as found + +- `~/.xscreensaver`, xscreensaver's configuration file. Remove it once the package is gone. +- `~/scripts/my-i3lock`, the predecessor's wrapper, which only the colour build understands. + +## Migration (ADR 0182) + +1. **Before the first push,** remove the colour build by hand: `sudo pacman -R i3lock-color`. + `pacman --noconfirm` will not replace a conflicting package. If the host installs `i3lock` before + it removes `i3lock-color`, the first push fails on the conflict. +2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc`: + - the `xset s` and `xset dpms` lines; + - the `while true; do xss-lock … my-i3lock; …; done &` loop. +3. Delete `~/.xscreensaver` and `~/scripts/my-i3lock`. + +## Blockers + +- `node-lock-screen`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows + them, `mctl` reads them as unknown. +- `xset` comes with the display server's module (`xorg`). diff --git a/modules/screen-lock/cmd/screen-lock-tools/args.go b/modules/screen-lock/cmd/screen-lock-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-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/screen-lock/cmd/screen-lock-tools/lock.go b/modules/screen-lock/cmd/screen-lock-tools/lock.go new file mode 100644 index 0000000..cd74ae9 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/lock.go @@ -0,0 +1,337 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +// The declared timeouts, which the session start sets (module.json's xinitrc contribution) and an +// inhibition returns to when it cannot read what was in force. +const ( + declaredLock = 1800 + declaredStandby = 1800 + declaredSuspend = 1800 + declaredOff = 3600 + + mostInhibit = 600 + + inhibitUnit = "screen-lock-inhibit" + lockerUnit = "screen-lock" +) + +// clockTicks is the kernel's USER_HZ, which /proc//stat counts a start time in: 100 on every +// architecture Arch Linux builds for. +const clockTicks = 100 + +func locker() string { return filepath.Join(operatorHome(), ".local", "bin", "screen-lock") } + +// LockState is what lock and locked answer. +type LockState struct { + Locked bool `json:"locked"` + // Since is when the locker started, when it runs. + Since string `json:"since,omitempty"` + PIDs []int `json:"pids"` + LockedHint *bool `json:"locked_hint,omitempty"` + Watcher bool `json:"watcher_running"` + Inhibited bool `json:"inhibited"` + Via string `json:"via,omitempty"` +} + +// Lock locks through logind when the watcher runs, else runs the locker itself. +func Lock() (LockState, error) { + s, err := findSession() + if err != nil { + return LockState{}, err + } + via := "" + switch { + case len(processesOf("i3lock")) > 0: + via = "already locked" + case len(processesOf("xss-lock")) > 0 && s.SessionID != "": + r, err := s.run(10*time.Second, "", "loginctl", "lock-session", s.SessionID) + if err != nil { + return LockState{}, err + } + if r.Code != 0 { + return LockState{}, fmt.Errorf("loginctl lock-session %s: %s", s.SessionID, strings.TrimSpace(r.Stderr)) + } + via = "logind, answered by xss-lock" + default: + // No watcher: the session start's loop is not running (a session begun before this module + // was assigned). The locker is run directly, under the account's service manager. + if err := s.detach(lockerUnit, locker()); err != nil { + return LockState{}, err + } + via = "the locker directly: xss-lock is not running in this session" + } + deadline := time.Now().Add(3 * time.Second) + for len(processesOf("i3lock")) == 0 && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + state := lockState(s) + state.Via = via + if !state.Locked { + return state, errors.New("asked to lock, and no locker is running 3s later") + } + return state, nil +} + +// Locked answers the lock state without changing it. +func Locked() (LockState, error) { + s := findEnvironment() + return lockState(s), nil +} + +func lockState(s Session) LockState { + pids := processesOf("i3lock") + st := LockState{Locked: len(pids) > 0, PIDs: pids, Watcher: len(processesOf("xss-lock")) > 0} + if st.PIDs == nil { + st.PIDs = []int{} + } + if len(pids) > 0 { + if at, ok := startTime(pids[0]); ok { + st.Since = at.Format(time.RFC3339) + } + } + if s.SessionID != "" { + if r, err := s.run(5*time.Second, "", "loginctl", "show-session", s.SessionID, "-p", "LockedHint", "--value"); err == nil && r.Code == 0 { + hint := strings.TrimSpace(r.Stdout) == "yes" + st.LockedHint = &hint + } + } + if s.RuntimeDir != "" { + if r, err := s.run(5*time.Second, "", "systemctl", "--user", "is-active", inhibitUnit+".service"); err == nil { + st.Inhibited = strings.TrimSpace(r.Stdout) == "active" + } + } + return st +} + +// startTime is when a process started, from its start in clock ticks after boot and the boot time. +func startTime(pid int) (time.Time, bool) { + stat, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat")) + if err != nil { + return time.Time{}, false + } + // The command name is in parentheses and may hold spaces; the fields after it are fixed. + end := strings.LastIndexByte(string(stat), ')') + if end < 0 { + return time.Time{}, false + } + fields := strings.Fields(string(stat[end+1:])) + // starttime is field 22 of the whole line; after "pid (comm)" it is the 20th. + if len(fields) < 20 { + return time.Time{}, false + } + ticks, err := strconv.ParseInt(fields[19], 10, 64) + if err != nil { + return time.Time{}, false + } + boot, ok := bootTime() + if !ok { + return time.Time{}, false + } + return boot.Add(time.Duration(ticks) * time.Second / clockTicks), true +} + +func bootTime() (time.Time, bool) { + raw, err := os.ReadFile(filepath.Join(procRoot, "stat")) + if err != nil { + return time.Time{}, false + } + for _, line := range strings.Split(string(raw), "\n") { + if v, ok := strings.CutPrefix(line, "btime "); ok { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err == nil { + return time.Unix(n, 0), true + } + } + } + return time.Time{}, false +} + +// IdleState is the screen's idle timeouts in force. +type IdleState struct { + LockAfterSeconds int `json:"lock_after_seconds"` + CycleSeconds int `json:"cycle_seconds"` + DPMSEnabled bool `json:"dpms_enabled"` + StandbySeconds int `json:"standby_seconds"` + SuspendSeconds int `json:"suspend_seconds"` + OffSeconds int `json:"off_seconds"` + MonitorOn bool `json:"monitor_on"` + Declared string `json:"declared"` + Note string `json:"note,omitempty"` +} + +var ( + screensaverLine = regexp.MustCompile(`timeout:\s+(\d+)\s+cycle:\s+(\d+)`) + dpmsLine = regexp.MustCompile(`Standby:\s+(\d+)\s+Suspend:\s+(\d+)\s+Off:\s+(\d+)`) +) + +func parseXsetQ(out string) (IdleState, error) { + var st IdleState + m := screensaverLine.FindStringSubmatch(out) + if m == nil { + return st, errors.New("xset q shows no screensaver timeout") + } + st.LockAfterSeconds, _ = strconv.Atoi(m[1]) + st.CycleSeconds, _ = strconv.Atoi(m[2]) + if d := dpmsLine.FindStringSubmatch(out); d != nil { + st.StandbySeconds, _ = strconv.Atoi(d[1]) + st.SuspendSeconds, _ = strconv.Atoi(d[2]) + st.OffSeconds, _ = strconv.Atoi(d[3]) + } + st.DPMSEnabled = strings.Contains(out, "DPMS is Enabled") + st.MonitorOn = strings.Contains(out, "Monitor is On") + st.Declared = fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", declaredLock, declaredStandby, declaredSuspend, declaredOff) + return st, nil +} + +// IdleChange is what screen_lock_idle was asked to change; nil fields stay. +type IdleChange struct { + LockAfter, Standby, Suspend, Off *int +} + +func idleChangeOf(args map[string]any) (IdleChange, error) { + var c IdleChange + for key, into := range map[string]**int{ + "lock_after_seconds": &c.LockAfter, "standby_seconds": &c.Standby, + "suspend_seconds": &c.Suspend, "off_seconds": &c.Off, + } { + if _, given := args[key]; !given { + continue + } + n, err := whole(args, key, 0, 0, 24*3600) + if err != nil { + return c, err + } + *into = &n + } + return c, nil +} + +func (c IdleChange) empty() bool { + return c.LockAfter == nil && c.Standby == nil && c.Suspend == nil && c.Off == nil +} + +// Idle reads the timeouts, and changes those asked for. +func Idle(change IdleChange) (IdleState, error) { + s, err := findSession() + if err != nil { + return IdleState{}, err + } + before, err := xsetQ(s) + if err != nil { + return IdleState{}, err + } + if change.empty() { + return before, nil + } + if change.LockAfter != nil { + cycle := before.CycleSeconds + if *change.LockAfter > 0 && cycle == 0 { + cycle = *change.LockAfter + } + if err := xset(s, "s", strconv.Itoa(*change.LockAfter), strconv.Itoa(cycle)); err != nil { + return IdleState{}, err + } + } + if change.Standby != nil || change.Suspend != nil || change.Off != nil { + pick := func(c *int, was int) string { + if c != nil { + return strconv.Itoa(*c) + } + return strconv.Itoa(was) + } + if err := xset(s, "dpms", pick(change.Standby, before.StandbySeconds), pick(change.Suspend, before.SuspendSeconds), + pick(change.Off, before.OffSeconds)); err != nil { + return IdleState{}, err + } + } + after, err := xsetQ(s) + if err != nil { + return IdleState{}, err + } + after.Note = "changed for this session only; the declared timeouts return at the next login" + return after, nil +} + +func xsetQ(s Session) (IdleState, error) { + r, err := s.run(5*time.Second, "", "xset", "q") + if err != nil { + return IdleState{}, err + } + if r.Code != 0 { + return IdleState{}, fmt.Errorf("xset q: %s", strings.TrimSpace(r.Stderr)) + } + return parseXsetQ(r.Stdout) +} + +func xset(s Session, args ...string) error { + r, err := s.run(5*time.Second, "", "xset", args...) + if err != nil { + return err + } + if r.Code != 0 { + return fmt.Errorf("xset %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr)) + } + return nil +} + +// InhibitResult is what screen_lock_inhibit answers. +type InhibitResult struct { + Inhibited bool `json:"inhibited"` + Until string `json:"until,omitempty"` + Restores string `json:"restores,omitempty"` +} + +// Inhibit keeps the screen on for minutes, then restores the timeouts that were in force; 0 ends an +// inhibition now. The waiting runs under the account's service manager, so it outlives this call, +// and stopping it restores at once. +func Inhibit(minutes int) (InhibitResult, error) { + s, err := findSession() + if err != nil { + return InhibitResult{}, err + } + if minutes == 0 { + r, err := s.run(10*time.Second, "", "systemctl", "--user", "stop", inhibitUnit+".service") + if err != nil { + return InhibitResult{}, err + } + if r.Code != 0 { + return InhibitResult{}, fmt.Errorf("ending the inhibition: %s", strings.TrimSpace(r.Stderr)) + } + return InhibitResult{Inhibited: false}, nil + } + // What to return to: what is in force now, unless an inhibition is already holding it at off. + was, err := xsetQ(s) + if err != nil { + return InhibitResult{}, err + } + if lockState(s).Inhibited || (was.LockAfterSeconds == 0 && !was.DPMSEnabled) { + was = IdleState{LockAfterSeconds: declaredLock, CycleSeconds: declaredLock, DPMSEnabled: true, + StandbySeconds: declaredStandby, SuspendSeconds: declaredSuspend, OffSeconds: declaredOff} + } + script := inhibitScript(minutes, was) + if err := s.detach(inhibitUnit, "/bin/sh", "-c", script); err != nil { + return InhibitResult{}, err + } + return InhibitResult{Inhibited: true, Until: time.Now().Add(time.Duration(minutes) * time.Minute).Format(time.RFC3339), + Restores: fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", was.LockAfterSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds)}, nil +} + +func inhibitScript(minutes int, was IdleState) string { + dpms := "+dpms" + if !was.DPMSEnabled { + dpms = "-dpms" + } + return fmt.Sprintf("restore() { xset s %d %d; xset dpms %d %d %d; xset %s; }; "+ + "trap 'restore; exit 0' TERM INT; xset s off -dpms; sleep %d & wait; restore", + was.LockAfterSeconds, was.CycleSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds, dpms, minutes*60) +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/lock_test.go b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go new file mode 100644 index 0000000..b6fc042 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/lock_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const nobody = 4194400 + +// xset q as the laptop answered it on 2026-10-04 (keyboard lines shortened). +const xsetQOutput = `Keyboard Control: + auto repeat: on key click percent: 0 LED mask: 00000000 +Screen Saver: + prefer blanking: yes allow exposures: yes + timeout: 600 cycle: 600 +Colors: + default colormap: 0x20 BlackPixel: 0x0 WhitePixel: 0xffffff +DPMS (Display Power Management Signaling): + Standby: 1800 Suspend: 1800 Off: 3600 + DPMS is Enabled + Monitor is On +` + +func TestTheTimeoutsAreReadFromXset(t *testing.T) { + st, err := parseXsetQ(xsetQOutput) + if err != nil { + t.Fatal(err) + } + if st.LockAfterSeconds != 600 || st.CycleSeconds != 600 || !st.DPMSEnabled || st.StandbySeconds != 1800 || + st.SuspendSeconds != 1800 || st.OffSeconds != 3600 || !st.MonitorOn { + t.Fatalf("%+v", st) + } + if _, err := parseXsetQ("nothing"); err == nil { + t.Fatal("an answer without a screensaver was accepted") + } +} + +func TestAProcessStartsWhenItsStatAndTheBootTimeSay(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "i3lock") + // A command name with a space and a parenthesis, which a naive split gets wrong. + stat := strconv.Itoa(nobody) + " (i3 lock) x) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 12345 0 0\n" + if err := os.WriteFile(filepath.Join(procRoot, strconv.Itoa(nobody), "stat"), []byte(stat), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(procRoot, "stat"), []byte("cpu 1 2 3\nbtime 1700000000\n"), 0o644); err != nil { + t.Fatal(err) + } + at, ok := startTime(nobody) + if !ok || !at.Equal(time.Unix(1700000000, 0).Add(123450*time.Millisecond)) { + t.Fatalf("%v %v", at, ok) + } +} + +// lockingMachine is a session whose loginctl, asked to lock, starts a (fake) i3lock. +func lockingMachine(t *testing.T, watcher bool) string { + t.Helper() + fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1", "XDG_SESSION_ID=4") + if watcher { + fakeProcess(t, nobody+1, "xss-lock", "DISPLAY=:1") + } + if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil { + t.Fatal(err) + } + lockNow := `mkdir -p "$PROC/` + strconv.Itoa(nobody+2) + `" && echo i3lock > "$PROC/` + strconv.Itoa(nobody+2) + `/comm"` + bin := fakeBinaries(t, map[string]string{ + "loginctl": `echo "loginctl $*" >> "$LOG" +case "$1" in lock-session) ` + lockNow + ` ;; show-session) echo yes ;; esac`, + "systemd-run": `echo "systemd-run $*" >> "$LOG"; ` + lockNow, + "systemctl": `echo "systemctl $*" >> "$LOG"; echo inactive`, + }) + t.Setenv("LOG", filepath.Join(bin, "log")) + t.Setenv("PROC", procRoot) + return bin +} + +func TestLockGoesThroughLogindSoTheOneLockerAnswers(t *testing.T) { + bin := lockingMachine(t, true) + st, err := Lock() + if err != nil { + t.Fatal(err) + } + if !st.Locked || !st.Watcher || st.LockedHint == nil || !*st.LockedHint || !strings.Contains(st.Via, "logind") { + t.Fatalf("%+v", st) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "loginctl lock-session 4\n") || strings.Contains(string(log), "systemd-run") { + t.Fatalf("asked:\n%s", log) + } + again, err := Lock() + if err != nil || again.Via != "already locked" { + t.Fatalf("locking a locked screen: %+v, %v", again, err) + } +} + +func TestWithoutTheWatcherTheLockerRunsUnderTheAccountsServiceManager(t *testing.T) { + bin := lockingMachine(t, false) + st, err := Lock() + if err != nil || !st.Locked || !strings.Contains(st.Via, "xss-lock is not running") { + t.Fatalf("%+v, %v", st, err) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "--unit=screen-lock --setenv=DISPLAY=:1 --setenv=XDG_SESSION_ID=4 -- ") || + !strings.Contains(string(log), "/.local/bin/screen-lock") { + t.Fatalf("asked:\n%s", log) + } +} + +func TestLockedWithoutASessionIsAnAnswerNotAnError(t *testing.T) { + fakeMachine(t) + st, err := Locked() + if err != nil || st.Locked || st.Watcher || st.PIDs == nil { + t.Fatalf("%+v, %v", st, err) + } + if _, err := Lock(); !errors.Is(err, ErrNoSession) { + t.Fatalf("lock without a session: %v", err) + } +} + +func TestIdleChangesOnlyWhatWasAskedAndSaysForHowLong(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + bin := fakeBinaries(t, map[string]string{"xset": `echo "xset $*" >> "$LOG"; [ "$1" = q ] && cat "$Q"; true`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + q := filepath.Join(bin, "q") + if err := os.WriteFile(q, []byte(xsetQOutput), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("Q", q) + if st, err := Idle(IdleChange{}); err != nil || st.Note != "" || st.LockAfterSeconds != 600 { + t.Fatalf("read: %+v, %v", st, err) + } + c, err := idleChangeOf(map[string]any{"lock_after_seconds": float64(900), "off_seconds": float64(7200)}) + if err != nil { + t.Fatal(err) + } + st, err := Idle(c) + if err != nil || !strings.Contains(st.Note, "next login") { + t.Fatalf("%+v, %v", st, err) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "xset s 900 600\n") || !strings.Contains(string(log), "xset dpms 1800 1800 7200\n") { + t.Fatalf("asked:\n%s", log) + } + if _, err := idleChangeOf(map[string]any{"off_seconds": float64(-1)}); err == nil { + t.Fatal("a negative timeout was accepted") + } +} + +func TestAnInhibitionTurnsIdleOffAndRestoresWhatWasThereWhenItEndsOrIsStopped(t *testing.T) { + was := IdleState{LockAfterSeconds: 1800, CycleSeconds: 1800, DPMSEnabled: true, StandbySeconds: 1800, SuspendSeconds: 1800, OffSeconds: 3600} + script := inhibitScript(2, was) + if !strings.Contains(script, "xset s off -dpms; sleep 120 & wait; restore") || + !strings.Contains(script, "restore() { xset s 1800 1800; xset dpms 1800 1800 3600; xset +dpms; }") || + !strings.Contains(script, "trap 'restore; exit 0' TERM") { + t.Fatalf("%s", script) + } + // Run it for real with a fake xset, stopped early as systemctl stop would. + dir := t.TempDir() + bin := fakeBinaries(t, map[string]string{"xset": `echo "$*" >> "` + filepath.Join(dir, "log") + `"`}) + _ = bin + cmd := exec.Command("/bin/sh", "-c", inhibitScript(1, was)) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + _ = cmd.Process.Signal(os.Interrupt) + _ = cmd.Wait() + got, _ := os.ReadFile(filepath.Join(dir, "log")) + if string(got) != "s off -dpms\ns 1800 1800\ndpms 1800 1800 3600\n+dpms\n" { + t.Fatalf("the timeouts were not restored on stop:\n%s", got) + } +} + +func TestTheDeclaredTimeoutsAreTheSessionStartsOwn(t *testing.T) { + m := readManifest(t) + code := m.Shell[0].Code + if !strings.Contains(code, "xset s "+strconv.Itoa(declaredLock)+" "+strconv.Itoa(declaredLock)+"\n") || + !strings.Contains(code, "xset dpms "+strconv.Itoa(declaredStandby)+" "+strconv.Itoa(declaredSuspend)+" "+strconv.Itoa(declaredOff)+"\n") { + t.Fatalf("the tools' declared values and the session start's disagree:\n%s", code) + } +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/main.go b/modules/screen-lock/cmd/screen-lock-tools/main.go new file mode 100644 index 0000000..d5bbab9 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/main.go @@ -0,0 +1,78 @@ +// screen-lock's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of +// node-lock-screen's verb `lock`, and its own tools for the idle timeouts, 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-lock-screen.lock", + Description: "Lock the operator's session now. It goes through logind, so the one locker the " + + "session runs answers it, as the lock key and a closed lid do. Answers whether the screen is " + + "locked and since when.", + Run: func(map[string]any) (any, error) { return Lock() }, + }, + { + Name: "screen_lock_idle", + Description: "The screen's idle timeouts: after how long idle the session locks (the X screensaver) " + + "and when the displays go to standby, suspend and off (DPMS). Give any of them to change it for " + + "this session; the declared ones return at the next login.", + Input: map[string]any{ + "lock_after_seconds": map[string]any{"type": "integer", "description": "idle time before the lock; 0 never"}, + "standby_seconds": map[string]any{"type": "integer", "description": "DPMS standby; 0 never"}, + "suspend_seconds": map[string]any{"type": "integer", "description": "DPMS suspend; 0 never"}, + "off_seconds": map[string]any{"type": "integer", "description": "DPMS off; 0 never"}, + }, + Run: func(args map[string]any) (any, error) { + change, err := idleChangeOf(args) + if err != nil { + return nil, err + } + return Idle(change) + }, + }, + { + Name: "screen_lock_inhibit", + Description: "Keep the screen on and unlocked for a while — a presentation, a film, a long read: " + + "no idle lock and no display power-off for `minutes`, then the timeouts as they were. 0 ends an " + + "inhibition early. A lock asked for by hand, by the lid or before suspend still locks.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "minutes": map[string]any{"type": "integer", "description": fmt.Sprintf("how long, 1-%d; 0 ends it now", mostInhibit)}, + }, + "required": []string{"minutes"}, + }, + Run: func(args map[string]any) (any, error) { + if _, given := args["minutes"]; !given { + return nil, fmt.Errorf("minutes is required") + } + minutes, err := whole(args, "minutes", 0, 0, mostInhibit) + if err != nil { + return nil, err + } + return Inhibit(minutes) + }, + }, + { + Name: "screen_lock_locked", + Description: "Is the operator's session locked now, and since when; whether the lock watcher " + + "(xss-lock) runs, and whether an inhibition is keeping the screen on.", + Run: func(map[string]any) (any, error) { return Locked() }, + }, + } +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-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/screen-lock/cmd/screen-lock-tools/manifest_test.go b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go new file mode 100644 index 0000000..2b20ad7 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-tools/manifest_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// screen-lock's shape (novox/hq ADR 0208, research 026/04): it claims node-lock-screen serving lock, +// requires the X display on its own machine, installs the watcher and the distribution's locker, +// declares the colour build and xscreensaver absent, places its locker, and starts the watcher and +// the timeouts once, from the session's start. The lock key is the window manager's. + +func TestItClaimsTheLockScreenSeatServingLockAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "screen-lock" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-lock-screen", Scope: "node", Serves: []string{"lock"}}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("requires: %v", m.Requires) + } +} + +func TestTheDistributionsLockerReplacesTheColourBuildAndXscreensaverGoes(t *testing.T) { + m := readManifest(t) + present, absent := m.packages() + if !reflect.DeepEqual(present, []string{"xss-lock", "i3lock"}) || !reflect.DeepEqual(absent, []string{"i3lock-color", "xscreensaver"}) { + t.Fatalf("packages: %v, absent %v", present, absent) + } + m.sameAsSource(t, "wrapper", "files/bin/screen-lock") + wrapper := m.resource(t, "wrapper") + if wrapper["mode"] != "0755" || wrapper["path"] != "${machine:account-home}/.local/bin/screen-lock" { + t.Fatalf("the locker: %v", wrapper) + } + c := wrapper["content"].(string) + for _, colourOnly := range []string{"--ring-color", "--blur", "--clock", "--indicator", "--time-str"} { + if strings.Contains(c, colourOnly) { + t.Errorf("the wrapper passes %s, which only the colour build knows", colourOnly) + } + } + if !strings.Contains(c, "XSS_SLEEP_LOCK_FD}<&-") { + t.Error("the locker must not inherit the sleep lock") + } +} + +func TestTheSessionStartSetsTheTimeoutsAndKeepsOneWatcherForTheSession(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" { + t.Fatalf("one xinitrc contribution in normal: %+v", m.Shell) + } + code := m.Shell[0].Code + if strings.Count(code, "xss-lock --") != 1 || !strings.Contains(code, "--transfer-sleep-lock") || + !strings.Contains(code, "while kill -0 $$") || !strings.HasSuffix(strings.TrimSpace(code), "&") { + t.Fatalf("the watcher: %q", code) + } + if strings.Contains(code, "xscreensaver") || strings.Contains(code, "my-i3lock") { + t.Fatalf("names what it replaced: %q", code) + } + for _, r := range m.Resources { + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("xss-lock needs the login session and is never a unit: %v", r) + } + } +} + +func TestTheLockKeyIsTheWindowManagersNotThisModules(t *testing.T) { + m := readManifest(t) + // The i3 module binds $mod+Delete to logind's lock, which names no locker; a binding here as well + // would be i3's duplicate. + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") { + t.Fatalf("a key binding: %v", r) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/screen-lock/cmd/screen-lock-tools/session.go b/modules/screen-lock/cmd/screen-lock-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-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/screen-lock/cmd/screen-lock-tools/session_test.go b/modules/screen-lock/cmd/screen-lock-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/screen-lock/cmd/screen-lock-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/screen-lock/files/bin/screen-lock b/modules/screen-lock/files/bin/screen-lock new file mode 100755 index 0000000..7e13946 --- /dev/null +++ b/modules/screen-lock/files/bin/screen-lock @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before +# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it. +# +# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour +# build the predecessor used is not in the distribution; it can come back as a pinned archive +# (ADR 0205), and then only these options change. +# +# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends +# once it is released. The locker must not inherit it, or the machine would wait for the unlock +# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is +# xss-lock's own documented pattern for i3lock. +set -u + +options=(--color=000000 --show-failed-attempts --ignore-empty-password) + +# One locker: a second press of the key, or a lock while locked, changes nothing. +if pgrep -xu "$EUID" i3lock >/dev/null; then + exit 0 +fi + +if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then + kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; } + trap kill_i3lock TERM INT + i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&- + exec {XSS_SLEEP_LOCK_FD}<&- + while kill_i3lock -0; do + sleep 0.5 + done +else + trap 'kill %%' TERM INT + i3lock --nofork "${options[@]}" & + wait +fi diff --git a/modules/screen-lock/go.mod b/modules/screen-lock/go.mod new file mode 100644 index 0000000..b049dcf --- /dev/null +++ b/modules/screen-lock/go.mod @@ -0,0 +1,5 @@ +module screenlock + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/screen-lock/go.sum b/modules/screen-lock/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/screen-lock/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/screen-lock/module.json b/modules/screen-lock/module.json new file mode 100644 index 0000000..d8ca419 --- /dev/null +++ b/modules/screen-lock/module.json @@ -0,0 +1,78 @@ +{ + "module": "screen-lock", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-lock-screen", + "scope": "node", + "serves": [ + "lock" + ] + } + ], + "tools": [ + "screen_lock_idle", + "screen_lock_inhibit", + "screen_lock_locked" + ], + "shell": [ + { + "for": "xinitrc", + "slot": "normal", + "code": "# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,\n# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on\n# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and\n# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the\n# session's own process, which becomes the window manager).\nxset s 1800 1800\nxset dpms 1800 1800 3600\n(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- \"$HOME/.local/bin/screen-lock\"; sleep 2; done) &\n" + } + ], + "resources": [ + { + "id": "colour-locker", + "type": "package", + "package": "i3lock-color", + "absent": true + }, + { + "id": "screensaver", + "type": "package", + "package": "xscreensaver", + "absent": true + }, + { + "id": "watcher", + "type": "package", + "package": "xss-lock" + }, + { + "id": "locker", + "type": "package", + "package": "i3lock" + }, + { + "id": "wrapper", + "type": "file", + "path": "${machine:account-home}/.local/bin/screen-lock", + "owner": "${machine:account}", + "mode": "0755", + "content": "#!/usr/bin/env bash\n# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before\n# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.\n#\n# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour\n# build the predecessor used is not in the distribution; it can come back as a pinned archive\n# (ADR 0205), and then only these options change.\n#\n# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends\n# once it is released. The locker must not inherit it, or the machine would wait for the unlock\n# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is\n# xss-lock's own documented pattern for i3lock.\nset -u\n\noptions=(--color=000000 --show-failed-attempts --ignore-empty-password)\n\n# One locker: a second press of the key, or a lock while locked, changes nothing.\nif pgrep -xu \"$EUID\" i3lock >/dev/null; then\n\texit 0\nfi\n\nif [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then\n\tkill_i3lock() { pkill -xu \"$EUID\" \"$@\" i3lock; }\n\ttrap kill_i3lock TERM INT\n\ti3lock \"${options[@]}\" {XSS_SLEEP_LOCK_FD}<&-\n\texec {XSS_SLEEP_LOCK_FD}<&-\n\twhile kill_i3lock -0; do\n\t\tsleep 0.5\n\tdone\nelse\n\ttrap 'kill %%' TERM INT\n\ti3lock --nofork \"${options[@]}\" &\n\twait\nfi\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/screen-lock-tools", + "binary": "screen-lock-tools", + "loads": [ + "screen-lock-tools" + ] + } + ] + } +} diff --git a/modules/xorg/README.md b/modules/xorg/README.md new file mode 100644 index 0000000..cdc9785 --- /dev/null +++ b/modules/xorg/README.md @@ -0,0 +1,189 @@ +# xorg + +The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2 step 2). + +- **Claims `node-display-server`** and serves its verbs `displays` and `layout`. +- **Provides `x11-display` with the machine's reach**: i3, xterm, picom and the X lock screen require + it, and a requirement for it is met only by this module on the same machine. It is never pulled in + for whatever asked. +- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device + and a connected display. See *Blockers* for why not `graphical-session`. +- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`, + `xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine, + so it belongs to that machine model's hardware module. + +## What it owns + +| path | class (ADR 0182) | what | +|---|---|---| +| `~/.xinitrc`, a block at the start | written into (`block`, `at: start`) | the session's start, below | +| `~/.config/xorg/` | owned directory | | +| `~/.config/xorg/xresources` | owned | the mesh's X resources: font rendering (`Xft.*`, DPI 96) and the three `xresources` slots | + +**The session's start**, in ADR 0208 §5's order: + +1. It sources `~/.config/mesh/environment.sh` (ADR 0203). If pam did not hand over a session bus, it + takes the user manager's socket, never a second bus. +2. It imports an explicit list of the session's words into the user manager and D-Bus activation, + only those that are set. The list is `DISPLAY`, `XAUTHORITY`, `XDG_SESSION_TYPE`, + `XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`, `XDG_CONFIG_HOME`, `XDG_DATA_DIRS`, `GTK_THEME`, + `GTK2_RC_FILES`, `QT_QPA_PLATFORMTHEME`, `QT_STYLE_OVERRIDE`, `QT_SELECT`, `XCURSOR_THEME`, + `XCURSOR_SIZE` and `TERMINAL`. It never uses `--all`. +3. It merges `~/.config/xorg/xresources` with `xrdb -nocpp`, then `~/.Xresources` if you keep one, + so yours win. +4. It runs `autorandr --change`, which applies the saved profile matching the connected monitors. +5. It runs the `xinitrc` slots `first` and `normal`, then sources **`~/.xinitrc.local`** if it exists. +6. It runs the `last` slot, where the holder of `node-display-session` starts the session (`exec i3`). + +**Who contributes where**, among the desktop's modules: + +| slot | module | what | +|---|---|---| +| `first` | `gnome-keyring` | `SSH_AUTH_SOCK` | +| `normal` | `adwaita` | its GSettings keys | +| `normal` | `clipmenu` | `clipmenud &` | +| `normal` | `feh` | the wallpaper | +| `normal` | `i3status-rust` | the bar watchdog | +| `normal` | `screen-lock` | the timeouts and the `xss-lock` loop | +| `last` | `i3` | `exec i3` | + +**Slot `last` is the session's.** A module contributing session lines uses `first` or `normal`. +Code in `last` that sorts after the session holder's name would come after its `exec` and never run. + +**Why `~/.xinitrc.local`.** The block ends by starting the session, so no line below it runs. Your own +session lines go into `~/.xinitrc.local`. Like `~/.zshrc.local`, it is yours: found, never written. +This is the one step this module adds to ADR 0208 §5. Without it, a line no module carries yet (a +`DOTNET_ROOT`, a wallpaper) would have nowhere to run. + +**Resources without the preprocessor.** `xrdb -nocpp` needs no C compiler on the machine, and it +skips a line starting with `#`. The controller precedes each contribution with a `# ` line, +which is a cpp directive error under plain `xrdb -merge` but is skipped here. + +## Tools + +| tool | | what | +|---|---|---| +| `node-display-server.displays` | r | the screen and every output: position, rotation, size, DPI, the monitor's EDID identity (manufacturer, product, serial, name, fingerprint), current and preferred mode, every mode and rate, the autorandr profile in force | +| `node-display-server.layout` | r/a | autorandr profiles: `list` (each with the monitors it is for, which match now, which is current), `save` (`replace` to overwrite), `apply` | +| `xorg_set_mode` | a | one output's mode, rate, rotation, scale, position (x/y or beside another), off, primary; `dry_run` | +| `xorg_primary` | r/a | which output is primary; set it | +| `xorg_dpi` | r/a | `Xft.dpi` beside each monitor's physical DPI; set it for the running session | +| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings | +| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name | +| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options | +| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) | +| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) | + +All answers are structured. Each tool that changes the running server says how long the change lasts. + +**How a tool reaches the session** (`internal/desktop`, the same copy in every desktop module). The +runtime is a system service running as the operator account. It has no `DISPLAY`, no `XAUTHORITY` and +no session bus. The tool reads them from the session's own processes: + +1. It looks at the account's processes, preferring the window manager. +2. It reads only a fixed list of words, never the rest. A session's environment held secrets on these + machines. +3. It asks logind whether that session is active and local. +4. It checks that the display's socket still exists. + +The bus it hands on is the user manager's (`unix:path=/run/user//bus`). With no session, a tool +answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`. + +The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump +into a PNG itself. + +## What it improves + +- **One environment.** The session sources the shells' environment file. It no longer exports a + hand-kept second copy, and never sources the predecessor's secrets file. +- **No compiler needed for the mesh's resources**, and contributions are placed in order rather than + `#include`d. +- **Monitor layouts by the monitors' identity** (autorandr, research 026/04), not scripts with port + names baked in. The package's own udev rule and service apply the matching profile on hotplug. +- **No second bus.** The `dbus-launch` fallback that once ran the whole session on a private bus is + gone. That stale session can still be seen today on one workstation: `session_bus` in a tool's + answer shows it. + +## What it leaves found + +`~/.xprofile`, `~/.Xresources`, `~/.Xresources.d/`, `~/.screenlayout/`, the arandr scripts, every +line of yours below the block, and `/etc/X11/xinit/xinitrc.d/`. + +## The one-off migration (ADR 0182) + +**Assign the desktop's modules together** (`xorg`, `lemurs`, `i3`, `xterm`, `adwaita` and the +companions above) and do this migration first. **Until `i3` is assigned, nothing changes for you.** The block's `last` slot is empty, so the block +runs and falls through to your own lines below it, which still end in `exec i3`. The environment is +sourced twice and the import runs twice, which is harmless. + +**Once `i3` is assigned, nothing below the block runs.** Before that push, sort today's +`~/.xinitrc` lines (both workstations hold the same file): + +| today's line | where it goes | +|---|---| +| the `/etc/X11/xinit/xinitrc.d/?*.sh` loop | **delete**: the block imports `DISPLAY` and `XAUTHORITY` itself, and the login manager's X setup runs that directory too | +| `eval $(gnome-keyring-daemon --start …)` and `export SSH_AUTH_SOCK` | **delete** once `gnome-keyring` is assigned. PAM starts and unlocks the keyring, and that module names the ssh agent's socket in the `xinitrc` slot `first`. Until then, `~/.xinitrc.local` | +| `export PATH=…` (nine entries) | `~/.local/bin`, `~/scripts` and `~/scripts/bin` come from `zsh`'s environment already. **Delete** `~/scripts/i3-sessions/commands` and `~/scripts/mediahuis`: neither exists on either workstation. Move `~/.cargo/bin`, `~/.config/rofi/scripts`, `~/.dotnet` and `~/.dotnet/tools` to `~/.xinitrc.local` until a module carries them | +| `DOTNET_ROOT`, `DOTNET_CLI_TELEMETRY_OPTOUT` | `~/.xinitrc.local` | +| `XDG_CONFIG_HOME` | **delete**: `zsh` contributes it | +| `XDG_DATA_DIRS` with the flatpak directories | `~/.xinitrc.local` until the `flatpak` module | +| `QT_QPA_PLATFORMTHEME`, `GTK_THEME`, `QT_STYLE_OVERRIDE`, `GTK2_RC_FILES`, `QT_SELECT` | **delete** once `adwaita` is assigned (its environment contributions) | +| `XDG_SESSION_DESKTOP`, `XDG_CURRENT_DESKTOP` and their comment | **delete** once `i3` is assigned | +| `dbus-update-activation-environment --systemd …` and its comment | **delete**: the block's step 2 | +| `~/scripts/xdg-appearance \|\| true` | **delete** once `adwaita` is assigned | +| `MY_KV_PATH`, `MY_LIB_PATH`, `MY_STREAMING_PATH` | `~/.xinitrc.local` (they are yours) | +| `[ -f "$HOME/.config/hal/env" ] && . …` (the secrets file) | **delete** (ADR 0208 §5, research 027 Q2). Whatever in the session needed one of those tokens gets it the way research 027 settles | +| `xset s 1800`, `xset dpms 1800 1800 3600` | **delete** once `screen-lock` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` | +| `~/.fehbg &` | **delete** once `feh` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` | +| the `xss-lock` respawn loop | **delete** once `screen-lock` is assigned. Until then, `~/.xinitrc.local` | +| `exec i3 --shmlog-size=26214400` | **delete**: `i3` contributes `exec i3` to the `last` slot | + +Then **delete everything below the block**. The old `#!/bin/sh` line now sits below the block too and +means nothing there. The file is run with `sh` (by the login manager's entry and by `startx`), never +executed by its first line. + +**`~/.xprofile`.** The login manager's X setup sources it, and it sources `~/.xinitrc`. Once `i3` and +`lemurs` are assigned, the session entry `/etc/lemurs/wms/i3` runs `~/.xinitrc` itself, so **delete +`~/.xprofile`**. Its bus logic is the block's now. If you keep it, delete its `. ~/.xinitrc` line, or +the session starts from `.xprofile` and the login manager's entry is never reached. Either way works +once, but only one should. + +**`~/.Xresources`** holds `#include ".Xresources.d/xterm"`, `#include ".Xresources.d/xft"` and the +two `Xcursor` lines: + +- The `xft` include and `~/.Xresources.d/xft` are **deleted** now. This module's file carries the + same five values. +- The `xterm` include and `~/.Xresources.d/xterm` are **deleted** once `xterm` is assigned. Kept, + they win over the module's font and colours. +- The `Xcursor` lines are **deleted** once `adwaita` is assigned. +- A file left empty is deleted. + +**Monitor layouts.** For each place you use, arrange the monitors (`xorg_set_mode`, or arandr once +more), then `layout` `save` it under a name. Once every place has a profile, these retire: the +`~/.screenlayout/` and `~/scripts/.screenlayouts/` scripts, the laptop's hotplug rule, and its i3 +bindings. Those bindings belong to that machine's hardware module, not here. + +## What changes when it is assigned + +| | g14 (laptop) | shanks (desktop) | +|---|---|---| +| packages | `autorandr` installed; the rest are there already | the same | +| files | `~/.config/xorg/xresources` new; a block added at the start of `~/.xinitrc` | the same | +| running session | nothing: everything takes effect at the next login | nothing | +| next login, before `i3` is assigned | the block runs, then the old file below it. Fonts unchanged (96 DPI); `autorandr --change` does nothing with no profiles | the same. Its session moves to the user manager's bus at the next login, as it should have | +| next login, after `i3` is assigned | only the block: what the table above did not move is gone | the same | + +## Blockers + +- **The capability.** ADR 0208 §3 says `graphical-session` gates the display server. The host + reports that capability from its own environment. It is a root daemon with no `DISPLAY`, so the + capability is **no on both workstations** (`node show`, 2026-10-04). Gated on it, `xorg` could + never be assigned. This manifest uses **`seat`** instead: graphics hardware with a connected + display, yes on both. The ADR's wording needs a progressive insight, or the host needs a probe + that finds the session the way `internal/desktop` does. +- **Unassigning removes the packages.** The host takes a package away when its declaration goes, + and `xorg-server` is among them. Do not unassign `xorg` from a workstation you are sitting at + without another display server assigned. +- **The `# ` naming lines in the `xresources` slots** are safe only because this module + merges with `-nocpp`. If the controller rendered `!` for `xresources`, that would be the + format's own comment. diff --git a/modules/xorg/cmd/xorg/displays.go b/modules/xorg/cmd/xorg/displays.go new file mode 100644 index 0000000..26a1900 --- /dev/null +++ b/modules/xorg/cmd/xorg/displays.go @@ -0,0 +1,232 @@ +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" +) + +// Screen is the X screen as xrandr reports it. +type Screen struct { + Width int `json:"width"` + Height int `json:"height"` + MaxWidth int `json:"max_width"` + MaxHeight int `json:"max_height"` +} + +// Output is one connector: a monitor when connected. +type Output struct { + Name string `json:"name"` + Connected bool `json:"connected"` + Primary bool `json:"primary,omitempty"` + Enabled bool `json:"enabled"` + Geometry *Geometry `json:"geometry,omitempty"` + Rotation string `json:"rotation,omitempty"` + Reflect string `json:"reflect,omitempty"` + WidthMM int `json:"width_mm,omitempty"` + HeightMM int `json:"height_mm,omitempty"` + // DPI is the physical density of the current mode, from the size the monitor reports. + DPI float64 `json:"dpi,omitempty"` + Monitor *Identity `json:"monitor,omitempty"` + Current *ModeRate `json:"current,omitempty"` + Preferred *ModeRate `json:"preferred,omitempty"` + Modes []Mode `json:"modes,omitempty"` + + edid []byte +} + +// Geometry is where an output's picture sits on the screen. +type Geometry struct { + Width int `json:"width"` + Height int `json:"height"` + X int `json:"x"` + Y int `json:"y"` +} + +// Mode is one resolution and the refresh rates it is offered at. +type Mode struct { + Size string `json:"size"` + Rates []float64 `json:"rates"` +} + +// ModeRate is one resolution at one rate. +type ModeRate struct { + Size string `json:"size"` + Rate float64 `json:"rate"` +} + +// Identity is who the monitor is, from its EDID: what an autorandr profile is keyed by, and a name a +// person recognises. +type Identity struct { + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` + Serial string `json:"serial,omitempty"` + Name string `json:"name,omitempty"` + Year int `json:"year,omitempty"` + // Fingerprint is the first 12 hex characters of the EDID's sha256: one monitor, whatever port. + Fingerprint string `json:"fingerprint"` +} + +var ( + screenLine = regexp.MustCompile(`^Screen \d+: minimum \d+ x \d+, current (\d+) x (\d+), maximum (\d+) x (\d+)`) + geometryRe = regexp.MustCompile(`^(\d+)x(\d+)\+(-?\d+)\+(-?\d+)$`) + sizeMM = regexp.MustCompile(`\)\s+(\d+)mm x (\d+)mm`) +) + +// ParseXrandr reads `xrandr --prop` (or `--query`): the screen, and every output with its modes and, +// where the properties carry one, its EDID decoded. +func ParseXrandr(text string) (Screen, []Output) { + var screen Screen + var outs []Output + var cur *Output + inEDID := false + sc := bufio.NewScanner(strings.NewReader(text)) + sc.Buffer(make([]byte, 1<<20), 1<<22) + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "Screen "): + if m := screenLine.FindStringSubmatch(line); m != nil { + screen = Screen{atoi(m[1]), atoi(m[2]), atoi(m[3]), atoi(m[4])} + } + inEDID = false + case line != "" && line[0] != ' ' && line[0] != '\t': + outs = append(outs, parseOutputLine(line)) + cur = &outs[len(outs)-1] + inEDID = false + case cur == nil: + case strings.HasPrefix(line, "\t\t"): + if inEDID { + if b, err := hex.DecodeString(strings.TrimSpace(line)); err == nil { + cur.edid = append(cur.edid, b...) + } + } + case strings.HasPrefix(line, "\t"): + inEDID = strings.HasPrefix(strings.TrimSpace(line), "EDID:") + case strings.HasPrefix(line, " "): + inEDID = false + parseModeLine(cur, line) + } + } + for i := range outs { + o := &outs[i] + if len(o.edid) >= 128 { + o.Monitor = DecodeEDID(o.edid) + } + if o.Geometry != nil && o.WidthMM > 0 { + w := o.Geometry.Width + if o.Rotation == "left" || o.Rotation == "right" { + w = o.Geometry.Height + } + o.DPI = float64(int(float64(w)/(float64(o.WidthMM)/25.4)*10+0.5)) / 10 + } + } + return screen, outs +} + +func parseOutputLine(line string) Output { + f := strings.Fields(line) + o := Output{Name: f[0], Connected: len(f) > 1 && f[1] == "connected"} + for _, tok := range f[2:] { + if strings.HasPrefix(tok, "(") { + break + } + switch { + case tok == "primary": + o.Primary = true + case geometryRe.MatchString(tok): + g := geometryRe.FindStringSubmatch(tok) + o.Geometry = &Geometry{atoi(g[1]), atoi(g[2]), atoi(g[3]), atoi(g[4])} + o.Enabled = true + case tok == "normal" || tok == "left" || tok == "inverted" || tok == "right": + o.Rotation = tok + case tok == "X" || tok == "Y" || tok == "and" || tok == "axis": + o.Reflect = strings.TrimSpace(o.Reflect + " " + tok) + } + } + if o.Enabled && o.Rotation == "" { + o.Rotation = "normal" + } + if m := sizeMM.FindStringSubmatch(line); m != nil { + o.WidthMM, o.HeightMM = atoi(m[1]), atoi(m[2]) + } + return o +} + +// parseModeLine reads ` 2880x1800 60.00*+ 120.00 +`: a rate marked * is in use, + preferred, +// and xrandr writes the + as a token of its own after a rate not in use. A panel may mark several +// rates preferred; the first is the monitor's own preference. +func parseModeLine(o *Output, line string) { + f := strings.Fields(line) + if len(f) == 0 || !strings.Contains(f[0], "x") { + return + } + mode := Mode{Size: f[0]} + for _, tok := range f[1:] { + if tok == "+" { + if n := len(mode.Rates); n > 0 && o.Preferred == nil { + o.Preferred = &ModeRate{mode.Size, mode.Rates[n-1]} + } + continue + } + current := strings.Contains(tok, "*") + preferred := strings.Contains(tok, "+") + rate, err := strconv.ParseFloat(strings.Trim(tok, "*+"), 64) + if err != nil { + continue + } + mode.Rates = append(mode.Rates, rate) + if current { + o.Current = &ModeRate{mode.Size, rate} + } + if preferred && o.Preferred == nil { + o.Preferred = &ModeRate{mode.Size, rate} + } + } + o.Modes = append(o.Modes, mode) +} + +// DecodeEDID reads the vendor block and the descriptors of an EDID. +func DecodeEDID(e []byte) *Identity { + if len(e) < 128 { + return nil + } + sum := sha256.Sum256(e) + id := &Identity{Fingerprint: hex.EncodeToString(sum[:])[:12]} + v := uint16(e[8])<<8 | uint16(e[9]) + id.Manufacturer = string([]byte{ + byte('A' - 1 + (v>>10)&0x1f), byte('A' - 1 + (v>>5)&0x1f), byte('A' - 1 + v&0x1f), + }) + id.Product = fmt.Sprintf("%04X", uint16(e[10])|uint16(e[11])<<8) + if serial := uint32(e[12]) | uint32(e[13])<<8 | uint32(e[14])<<16 | uint32(e[15])<<24; serial != 0 { + id.Serial = strconv.FormatUint(uint64(serial), 10) + } + if e[17] != 0 { + id.Year = 1990 + int(e[17]) + } + for _, at := range []int{54, 72, 90, 108} { + d := e[at : at+18] + if d[0] != 0 || d[1] != 0 || d[2] != 0 { + continue + } + text := strings.TrimSpace(strings.SplitN(string(d[5:18]), "\n", 2)[0]) + switch d[3] { + case 0xfc: + id.Name = text + case 0xff: + if text != "" { + id.Serial = text + } + } + } + return id +} + +func atoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/modules/xorg/cmd/xorg/input.go b/modules/xorg/cmd/xorg/input.go new file mode 100644 index 0000000..6ffb7d5 --- /dev/null +++ b/modules/xorg/cmd/xorg/input.go @@ -0,0 +1,137 @@ +package main + +import ( + "bufio" + "regexp" + "strconv" + "strings" +) + +// Device is one input device the X server knows. +type Device struct { + ID int `json:"id"` + Name string `json:"name"` + Role string `json:"role"` // master or slave + Kind string `json:"kind"` // pointer or keyboard + Settings map[string]any `json:"settings,omitempty"` +} + +var deviceLine = regexp.MustCompile(`^[^A-Za-z0-9]*(.+?)\s+id=(\d+)\s+\[(master|slave|floating)\s+(pointer|keyboard)?`) + +// ParseDevices reads `xinput list`, the tree's drawing characters stripped. +func ParseDevices(text string) []Device { + var out []Device + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + m := deviceLine.FindStringSubmatch(sc.Text()) + if m == nil { + continue + } + id, _ := strconv.Atoi(m[2]) + kind := m[4] + if kind == "" { + kind = "floating" + } + out = append(out, Device{ID: id, Name: strings.TrimSpace(m[1]), Role: m[3], Kind: kind}) + } + return out +} + +// Knob is one setting input-set changes, and the libinput property that holds it. +type Knob struct { + Arg, Property, Kind string // Kind: bool or float +} + +// Knobs are what input-set may change, by libinput's own property names (libinput(4)). +var Knobs = []Knob{ + {"enabled", "Device Enabled", "bool"}, + {"tap", "libinput Tapping Enabled", "bool"}, + {"tap_drag", "libinput Tapping Drag Enabled", "bool"}, + {"natural_scroll", "libinput Natural Scrolling Enabled", "bool"}, + {"disable_while_typing", "libinput Disable While Typing Enabled", "bool"}, + {"left_handed", "libinput Left Handed Enabled", "bool"}, + {"middle_emulation", "libinput Middle Emulation Enabled", "bool"}, + {"accel_speed", "libinput Accel Speed", "float"}, +} + +var propLine = regexp.MustCompile(`^\s+(.+?) \(\d+\):\s*(.*)$`) + +// ParseProps reads `xinput list-props` into the settings Knobs name, as booleans and numbers. A +// device without a property (a keyboard has no tapping) simply lacks the setting. +func ParseProps(text string) map[string]any { + byProp := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + if m := propLine.FindStringSubmatch(sc.Text()); m != nil { + byProp[m[1]] = strings.TrimSpace(m[2]) + } + } + out := map[string]any{} + for _, k := range Knobs { + v, ok := byProp[k.Property] + if !ok { + continue + } + first := strings.TrimSpace(strings.Split(v, ",")[0]) + switch k.Kind { + case "bool": + out[k.Arg] = first == "1" + case "float": + if f, err := strconv.ParseFloat(first, 64); err == nil { + out[k.Arg] = f + } + } + } + if v, ok := byProp["libinput Accel Profile Enabled"]; ok { + profiles := []string{"adaptive", "flat", "custom"} + for i, bit := range strings.Split(v, ",") { + if strings.TrimSpace(bit) == "1" && i < len(profiles) { + out["accel_profile"] = profiles[i] + } + } + } + return out +} + +// Keyboard is the X keyboard map as setxkbmap reports it. +type Keyboard struct { + Rules string `json:"rules,omitempty"` + Model string `json:"model,omitempty"` + Layout string `json:"layout,omitempty"` + Variant string `json:"variant,omitempty"` + Options []string `json:"options"` +} + +// ParseKeyboard reads `setxkbmap -query`. +func ParseKeyboard(text string) Keyboard { + k := Keyboard{Options: []string{}} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + key, value, ok := strings.Cut(sc.Text(), ":") + if !ok { + continue + } + value = strings.TrimSpace(value) + switch strings.TrimSpace(key) { + case "rules": + k.Rules = value + case "model": + k.Model = value + case "layout": + k.Layout = value + case "variant": + k.Variant = value + case "options": + for _, o := range strings.Split(value, ",") { + if o = strings.TrimSpace(o); o != "" { + k.Options = append(k.Options, o) + } + } + } + } + return k +} + +// xkbName is what setxkbmap accepts as a layout, variant, model or option: letters, digits and the +// punctuation XKB names use. Anything else is refused before it reaches a command line. +var xkbName = regexp.MustCompile(`^[A-Za-z0-9_:+(),.-]+$`) diff --git a/modules/xorg/cmd/xorg/layout.go b/modules/xorg/cmd/xorg/layout.go new file mode 100644 index 0000000..8845fab --- /dev/null +++ b/modules/xorg/cmd/xorg/layout.go @@ -0,0 +1,105 @@ +package main + +import ( + "bufio" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Profile is one saved autorandr layout: the monitors it is for, by output and EDID fingerprint. +type Profile struct { + Name string `json:"name"` + Path string `json:"path"` + Monitors map[string]string `json:"monitors"` + Detected bool `json:"detected,omitempty"` + Current bool `json:"current,omitempty"` +} + +// profileName is what a profile may be called: it becomes a directory name. +var profileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +// ProfileDirs are where autorandr keeps profiles, the account's first (autorandr(1)). +func ProfileDirs(home string) []string { + cfg := os.Getenv("XDG_CONFIG_HOME") + if cfg == "" { + cfg = filepath.Join(home, ".config") + } + return []string{filepath.Join(cfg, "autorandr"), "/etc/xdg/autorandr"} +} + +// ReadProfiles lists the profiles in dirs; a name in an earlier directory hides a later one, as in +// autorandr itself. Each profile's monitors come from its `setup` file: ` ` per line. +func ReadProfiles(dirs []string) []Profile { + seen := map[string]bool{} + var out []Profile + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, e := range entries { + if !e.IsDir() || seen[e.Name()] { + continue + } + setup := filepath.Join(dir, e.Name(), "setup") + f, err := os.Open(setup) + if err != nil { + continue + } + p := Profile{Name: e.Name(), Path: filepath.Join(dir, e.Name()), Monitors: map[string]string{}} + sc := bufio.NewScanner(f) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) == 2 { + p.Monitors[fields[0]] = shortEDID(fields[1]) + } + } + f.Close() + seen[e.Name()] = true + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// shortEDID is the fingerprint DecodeEDID gives for the same monitor, so a profile's monitors and the +// connected ones are compared by one value. +func shortEDID(hexEDID string) string { + b := make([]byte, 0, len(hexEDID)/2) + for i := 0; i+1 < len(hexEDID); i += 2 { + var v byte + for _, c := range hexEDID[i : i+2] { + v <<= 4 + switch { + case c >= '0' && c <= '9': + v |= byte(c - '0') + case c >= 'a' && c <= 'f': + v |= byte(c - 'a' + 10) + case c >= 'A' && c <= 'F': + v |= byte(c - 'A' + 10) + default: + return hexEDID + } + } + b = append(b, v) + } + if id := DecodeEDID(b); id != nil { + return id.Fingerprint + } + return hexEDID +} + +// names reads autorandr's one-name-per-line answers (--detected, --current). +func names(text string) map[string]bool { + out := map[string]bool{} + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + out[strings.Fields(l)[0]] = true + } + } + return out +} diff --git a/modules/xorg/cmd/xorg/main.go b/modules/xorg/cmd/xorg/main.go new file mode 100644 index 0000000..fd28adf --- /dev/null +++ b/modules/xorg/cmd/xorg/main.go @@ -0,0 +1,167 @@ +// xorg's tools (novox/hq ADR 0208, research 026/05): node-display-server's verbs `displays` and +// `layout`, and the module's own tools for one output's mode, the primary output, DPI, input devices, +// the keyboard map, a screenshot and the server's log. +// +// Every tool that touches the screen acts in the operator's running session, which it finds through +// internal/desktop: the node's runtime has none of its own. With no session, the answer says so as +// structured data. A tool that changes the running server changes it until the next login; the +// answer says what makes it last. +package main + +import ( + "context" + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "xorg/internal/desktop" +) + +// The session's holders this module prefers as the session's own process. +var sessionHolders = []string{"i3", "sway", "openbox", "bspwm", "awesome", "xmonad"} + +func main() { + if err := stdio.Serve("", tools(desktop.Machine(sessionHolders...))); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// call bounds one tool call below the runtime's 30 s limit. +func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + return run(ctx, desktop.Args(args)) + } +} + +func tools(d desktop.Desk) []stdio.Tool { + x := xorg{d: d} + return []stdio.Tool{ + { + Name: "node-display-server.displays", + Description: "The X screen and every output: connected or not, primary, where it sits, its rotation, " + + "physical size and DPI, the monitor's identity from its EDID (manufacturer, product, serial, name, " + + "fingerprint), the current and preferred mode, and every mode with its rates. Disconnected outputs " + + "are listed by name only unless all is true. Also the autorandr profile in force, when one matches.", + Input: desktop.Schema(map[string]any{ + "all": desktop.Flag("list disconnected outputs in full (default false)"), + "modes": desktop.Flag("list every mode of each output (default true)"), + }), + Run: call(x.displays), + }, + { + Name: "node-display-server.layout", + Description: "Monitor layout profiles (autorandr), keyed by the connected monitors' identities, so one " + + "profile needs no machine's name. list: every profile with the monitors it is for, which match " + + "the monitors connected now and which is in force. save: the current arrangement under a name " + + "(replace true to overwrite). apply: load one. The profiles are the operator's data, never the mesh's.", + Input: desktop.Schema(map[string]any{ + "action": desktop.Enum("list, save or apply", "list", "save", "apply"), + "name": desktop.Str("the profile to save or apply"), + "replace": desktop.Flag("save over an existing profile of that name (default false)"), + }, "action"), + Run: call(x.layout), + }, + { + Name: "xorg_set_mode", + Description: "Change one output now, through xrandr: its mode (WxH, or auto for the preferred one), rate, " + + "rotation, scale, position (x/y, or beside another output), on or off, primary. dry_run answers " + + "the command without running it. Lasts until the next login or hotplug: save a layout profile to keep it.", + Input: desktop.Schema(map[string]any{ + "output": desktop.Str("the output, as displays names it (e.g. eDP-1)"), + "mode": desktop.Str("WxH, or auto for the monitor's preferred mode"), + "rate": desktop.Num("refresh rate in Hz"), + "rotate": desktop.Enum("rotation", "normal", "left", "right", "inverted"), + "scale": desktop.Num("scale factor, 0.25 to 4 (1 = none)"), + "x": desktop.Int("left edge on the screen, in pixels"), + "y": desktop.Int("top edge on the screen, in pixels"), + "relation": desktop.Enum("place it beside another output instead of at x/y", "right-of", "left-of", "above", "below", "same-as"), + "of": desktop.Str("the output the relation is to"), + "off": desktop.Flag("switch the output off"), + "primary": desktop.Flag("make it the primary output"), + "dry_run": desktop.Flag("answer the command, run nothing"), + }, "output"), + Run: call(x.setMode), + }, + { + Name: "xorg_primary", + Description: "Which output is primary (the one the bar's tray and new windows prefer); with output, make it primary now.", + Input: desktop.Schema(map[string]any{"output": desktop.Str("the output to make primary (optional)")}), + Run: call(x.primary), + }, + { + Name: "xorg_dpi", + Description: "The DPI X programs are told (Xft.dpi) beside each monitor's physical DPI. With value, set " + + "Xft.dpi and the screen's DPI now: programs started after it use it. The module's resources file " + + "sets it again at the next login (96 until it is a setting).", + Input: desktop.Schema(map[string]any{"value": desktop.Int("dots per inch, 48 to 480 (optional)")}), + Run: call(x.dpi), + }, + { + Name: "xorg_input_devices", + Description: "The input devices the X server knows: id, name, pointer or keyboard, master or slave, and " + + "for each pointer its libinput settings (tap, natural scroll, acceleration, disable while typing, " + + "left handed, enabled).", + Input: desktop.Schema(map[string]any{"name": desktop.Str("only devices whose name contains this (optional)")}), + Run: call(x.inputDevices), + }, + { + Name: "xorg_input_set", + Description: "Change libinput settings of a device now: tap, tap_drag, natural_scroll, disable_while_typing, " + + "left_handed, middle_emulation, enabled (true/false), accel_speed (-1 to 1). The device is an id or a " + + "name; a name applies to every device of that name that has the setting. Lasts until the device " + + "reconnects or the next login.", + Input: desktop.Schema(map[string]any{ + "device": desktop.Str("the device's id or exact name"), + "tap": desktop.Flag("tap to click"), + "tap_drag": desktop.Flag("tap and drag"), + "natural_scroll": desktop.Flag("natural (reversed) scrolling"), + "disable_while_typing": desktop.Flag("ignore the touchpad while typing"), + "left_handed": desktop.Flag("swap the buttons"), + "middle_emulation": desktop.Flag("both buttons together are the middle one"), + "enabled": desktop.Flag("the device takes input at all"), + "accel_speed": desktop.Num("pointer acceleration, -1 to 1"), + }, "device"), + Run: call(x.inputSet), + }, + { + Name: "xorg_keyboard", + Description: "The X keyboard map: rules, model, layout, variant and options. With layout, variant, model " + + "or options, set them now (options replace the current ones; an empty list clears them). Lasts " + + "until the next login.", + Input: desktop.Schema(map[string]any{ + "layout": desktop.Str("e.g. us, be, us,be"), + "variant": desktop.Str("e.g. intl"), + "model": desktop.Str("e.g. pc105"), + "options": desktop.List("e.g. [\"caps:escape\", \"compose:ralt\"]"), + }), + Run: call(x.keyboard), + }, + { + Name: "xorg_screenshot", + Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " + + "Written under the account's home (default ~/Pictures/Screenshots/screenshot-