Compare commits

..
Author SHA1 Message Date
jochen a44a1fc51e adwaita: the theme as a module, dark by default, for GTK, Qt, the portal and the cursor (hq ADR 0208)
GTK 3/4 settings, qt6ct, portals.conf and the default cursor as owned files.
GTK_THEME, GTK2_RC_FILES, the Qt words and XCURSOR_* as environment
contributions. The GSettings keys the portal serves go in the xinitrc slot,
replacing the predecessor's appearance script, and the cursor in the
xresources slot.

Qt is drawn by Fusion with qt6ct's darker palette instead of the
user-repository adwaita-qt, which is no longer developed. qt5ct is dropped. The
fonts are research 026's Inter and JetBrains Mono, and portals.conf routes the
Secret interface to gnome-keyring, which nothing answered.

The tools are appearance (dark or light per audience, switched for the session),
cursor, icons, and portal-check (which backend answers which interface, and why).
2026-10-04 13:21:47 +02:00
jochen e4abc6eb88 xterm: the terminal holds node-terminal-emulator; its resources through xorg's slot (hq ADR 0208)
It requires x11-display, names itself in TERMINAL, and contributes its X resources
to the xresources slot normal: today's palette and clipboard keys, JetBrainsMono
Nerd Font, 10000 lines of scrollback, all scoped to XTerm* instead of every Xt
program. It owns no file.

The tools are the seat's open, which starts a terminal through the account's
service manager so it outlives the runtime's restarts, and font (in force, what
fontconfig resolves it to, set for new terminals) and colours.
2026-10-04 13:21:47 +02:00
jochen 34829e39fe i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and
XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns
~/.config/i3/config, ending with the config.d include where other modules drop
their files, and /etc/lemurs/wms/i3, which runs the session's start.

Over today's identical config it drops the dead lxpolkit, the D-Bus-activated
portal, the xrdb merge xorg now does, and the execs that XDG autostart already
started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares
dex, which the desktop lacked.

The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus,
move, layout save/restore, exec, kill, bindings, config check, marks and the
scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify
script and user unit. It reloads only a configuration i3 -C accepts, and at its
start whatever i3 has not loaded.
2026-10-04 13:21:47 +02:00
jochen 98eb3fe33c lemurs: the login manager holds node-login-manager, its config in the current format (hq ADR 0208)
The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs
0.4's structure. It offers only the session scripts that modules place in
/etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which
skips the session's start. The service is enabled and never started, stopped or
restarted by a push.

The tools are the seat's sessions, the default session (lemurs's cache, through
sudo -n) and logins from the journal and lemurs's own log. The package swap from
lemurs-git is a one-off step for the operator, listed in the README.
2026-10-04 13:21:47 +02:00
jochen b2c170acda xorg: the display server holds node-display-server and writes the session's start (hq ADR 0208)
The X server, its start and its tools as one module. It provides x11-display with
the machine's reach, gated by the host's seat capability. It writes a block at the
start of ~/.xinitrc: the account's environment, an explicit import into the user
manager, the mesh's X resources merged without cpp, autorandr, the xinitrc slots,
~/.xinitrc.local, and the session's exec from the last slot.

Its Go tools serve the seat's displays and layout (autorandr profiles keyed by
EDID), and set-mode, primary, dpi, input devices and settings, keyboard, a
screenshot (xwd decoded in Go) and the server's log. internal/desktop is how
every desktop tool finds the operator's session from the runtime, which has none:
from the session's own processes, reading only its words, confirmed with logind.
2026-10-04 13:21:47 +02:00
190 changed files with 13678 additions and 14399 deletions
+103
View File
@@ -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).
@@ -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)
}
+84
View File
@@ -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),
},
}
}
+434
View File
@@ -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, `<desktop>-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
}
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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
+29
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
module adwaita
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+160
View File
@@ -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}
}
@@ -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")
}
}
+232
View File
@@ -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)
}
+445
View File
@@ -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/<pid>/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
}
@@ -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)
}
}
+118
View File
@@ -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"
]
}
]
}
}
-69
View File
@@ -1,69 +0,0 @@
# 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.
@@ -1,97 +0,0 @@
// 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
}
@@ -1,348 +0,0 @@
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: <dir>/clipmenu.<major>.<user>/.
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 "<crc> <length>".
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 "<nanoseconds> <first line>" 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
}
@@ -1,163 +0,0 @@
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)
}
}
@@ -1,90 +0,0 @@
// 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)
},
},
}
}
@@ -1,175 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -1,64 +0,0 @@
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)
}
@@ -1,423 +0,0 @@
// 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
}
@@ -1,174 +0,0 @@
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
}
@@ -1,5 +0,0 @@
# 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
-5
View File
@@ -1,5 +0,0 @@
module clipmenu
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-75
View File
@@ -1,75 +0,0 @@
{
"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"
]
}
]
}
}
-60
View File
@@ -1,60 +0,0 @@
# 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.
-97
View File
@@ -1,97 +0,0 @@
// 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
}
-355
View File
@@ -1,355 +0,0 @@
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...)
}
-160
View File
@@ -1,160 +0,0 @@
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)
}
}
-91
View File
@@ -1,91 +0,0 @@
// 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 (<b>, <i>, <u>, <a href>) 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() },
},
}
}
@@ -1,175 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -1,77 +0,0 @@
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)
}
-423
View File
@@ -1,423 +0,0 @@
// 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
}
@@ -1,174 +0,0 @@
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
}
-92
View File
@@ -1,92 +0,0 @@
# 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 = "<b>%s</b>\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
-73
View File
@@ -1,73 +0,0 @@
{
"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 = \"<b>%s</b>\\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"
]
}
]
}
}
-47
View File
@@ -1,47 +0,0 @@
# 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.
-97
View File
@@ -1,97 +0,0 @@
// 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
}
-53
View File
@@ -1,53 +0,0 @@
// 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() },
},
}
}
@@ -1,175 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -1,79 +0,0 @@
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)
}
-423
View File
@@ -1,423 +0,0 @@
// 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
}
-174
View File
@@ -1,174 +0,0 @@
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
}
-174
View File
@@ -1,174 +0,0 @@
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
}
@@ -1,92 +0,0 @@
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)
}
}
-6
View File
@@ -1,6 +0,0 @@
#!/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"
-3
View File
@@ -1,3 +0,0 @@
# 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
-71
View File
@@ -1,71 +0,0 @@
{
"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"
]
}
]
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 KiB

-101
View File
@@ -1,101 +0,0 @@
# 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/<uid>`). 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).
@@ -1,97 +0,0 @@
// 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
}
@@ -1,260 +0,0 @@
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
}
@@ -1,131 +0,0 @@
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)
}
}
@@ -1,57 +0,0 @@
// 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() },
},
}
}
@@ -1,175 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -1,64 +0,0 @@
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)
}
@@ -1,423 +0,0 @@
// 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
}
@@ -1,174 +0,0 @@
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
}
-4
View File
@@ -1,4 +0,0 @@
# 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
-3
View File
@@ -1,3 +0,0 @@
# 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
-5
View File
@@ -1,5 +0,0 @@
module gnomekeyring
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
-76
View File
@@ -1,76 +0,0 @@
{
"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"
]
}
]
}
}
+152
View File
@@ -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 /bin/sh "$HOME/.xinitrc"` |
**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/<NN>-<module>.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.
+286
View File
@@ -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
}
+371
View File
@@ -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")
}
+117
View File
@@ -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.<pid>` 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) + `"`
}
+191
View File
@@ -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),
},
}
}
+205
View File
@@ -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 /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)
}
}
+428
View File
@@ -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
}
}
+261
View File
@@ -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
}
+195
View File
@@ -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
# <NN>-<module>.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
+1 -1
View File
@@ -1,4 +1,4 @@
module feh
module i3
go 1.22
+160
View File
@@ -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}
}
@@ -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")
}
}
+232
View File
@@ -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)
}
+445
View File
@@ -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/<pid>/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
}
+255
View File
@@ -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)
}
}
File diff suppressed because one or more lines are too long
-86
View File
@@ -1,86 +0,0 @@
# 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.
@@ -1,97 +0,0 @@
// 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
}
@@ -1,306 +0,0 @@
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 "<index>:…", 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
}
@@ -1,141 +0,0 @@
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)
}
}
@@ -1,81 +0,0 @@
// 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 },
},
}
}
@@ -1,175 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -1,71 +0,0 @@
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)
}
@@ -1,423 +0,0 @@
// 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
}
@@ -1,174 +0,0 @@
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
}
@@ -1,64 +0,0 @@
#!/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
@@ -1,63 +0,0 @@
#!/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
-100
View File
@@ -1,100 +0,0 @@
# 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')"
@@ -1,34 +0,0 @@
# 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
}
}
@@ -1,135 +0,0 @@
# 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)
-33
View File
@@ -1,33 +0,0 @@
# 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
-5
View File
@@ -1,5 +0,0 @@
module i3statusrust
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
File diff suppressed because one or more lines are too long
+101
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More